diff --git a/src/main/java/com/magamochi/catalog/client/JikanClient.java b/src/main/java/com/magamochi/catalog/client/JikanClient.java index 1f202b9..ec131af 100644 --- a/src/main/java/com/magamochi/catalog/client/JikanClient.java +++ b/src/main/java/com/magamochi/catalog/client/JikanClient.java @@ -28,38 +28,34 @@ public interface JikanClient { includes = Exception.class) MangaResponse getMangaById(@PathVariable Long id); - record SearchResponse(List data) { - public record MangaData(Long mal_id, String title, List titles) { - public record TitleData(String type, String title) {} + record SearchResponse(List data) {} + + record MangaResponse(MangaData data) {} + + public record MangaData( + Long mal_id, + ImageData images, + String title, + List title_synonyms, + String status, + Boolean publishing, + String synopsis, + Float score, + Integer chapters, + PublishData published, + List authors, + List genres, + List explicit_genres) { + public record GenreData(Long mal_id, String name) {} + + public record ExplicitGenreData(Long mal_id, String name) {} + + public record ImageData(ImageUrls jpg) { + public record ImageUrls(String large_image_url) {} } - } - record MangaResponse(MangaData data) { - public record MangaData( - Long mal_id, - ImageData images, - String title, - List title_synonyms, - String status, - Boolean publishing, - String synopsis, - Float score, - Integer chapters, - PublishData published, - List authors, - List genres, - List explicit_genres) { - public record ImageData(ImageUrls jpg) { - public record ImageUrls(String large_image_url) {} - } + public record PublishData(OffsetDateTime from, OffsetDateTime to) {} - public record PublishData(OffsetDateTime from, OffsetDateTime to) {} - - public record AuthorData(Long mal_id, String name) {} - - public record GenreData(Long mal_id, String name) {} - - public record ExplicitGenreData(Long mal_id, String name) {} - } + public record AuthorData(Long mal_id, String name) {} } } diff --git a/src/main/java/com/magamochi/catalog/controller/ProxyController.java b/src/main/java/com/magamochi/catalog/controller/ProxyController.java new file mode 100644 index 0000000..7f5c083 --- /dev/null +++ b/src/main/java/com/magamochi/catalog/controller/ProxyController.java @@ -0,0 +1,77 @@ +package com.magamochi.catalog.controller; + +import com.magamochi.catalog.model.dto.MangaProxyDataDTO; +import com.magamochi.catalog.service.AniListService; +import com.magamochi.catalog.service.MyAnimeListService; +import com.magamochi.common.model.dto.DefaultResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/catalog") +@RequiredArgsConstructor +public class ProxyController { + private final AniListService aniListService; + private final MyAnimeListService myAnimeListService; + + @Operation( + summary = "Get manga data from AniList by ID", + description = + "Fetches manga information from AniList using the provided ID. This endpoint serves as a proxy to retrieve manga data without directly exposing the AniList API.", + tags = {"Catalog Proxy"}, + operationId = "getAniListMangaDataById") + @GetMapping("/proxy/anilist/{id}") + public DefaultResponseDTO getAniListMangaDataById(@PathVariable Long id) { + return DefaultResponseDTO.ok(MangaProxyDataDTO.from(aniListService.getMangaDataById(id), id)); + } + + @Operation( + summary = "Get manga data from AniList by title", + description = + "Fetches manga information from AniList using the provided title. This endpoint serves as a proxy to search for manga data without directly exposing the AniList API.", + tags = {"Catalog Proxy"}, + operationId = "searchAniListMangaDataByTitle") + @GetMapping("/proxy/anilist/{title}/search") + public DefaultResponseDTO> searchAniListMangaDataByTitle( + @PathVariable String title) { + var results = aniListService.getMangasByTitle(title); + return DefaultResponseDTO.ok( + results.entrySet().stream() + .map( + mangaDataEntry -> + MangaProxyDataDTO.from(mangaDataEntry.getValue(), mangaDataEntry.getKey())) + .toList()); + } + + @Operation( + summary = "Get manga data from MyAnimeList by ID", + description = + "Fetches manga information from MyAnimeList using the provided ID. This endpoint serves as a proxy to retrieve manga data without directly exposing the MyAnimeList API.", + tags = {"Catalog Proxy"}, + operationId = "getMyAnimeListMangaDataById") + @GetMapping("/proxy/myanimelist/{id}") + public DefaultResponseDTO getMyAnimeListMangaDataById(@PathVariable Long id) { + return DefaultResponseDTO.ok( + MangaProxyDataDTO.from(myAnimeListService.getMangaDataById(id), id)); + } + + @Operation( + summary = "Get manga data from MyAnimeList by title", + description = + "Fetches manga information from MyAnimeList using the provided title. This endpoint serves as a proxy to search for manga data without directly exposing the MyAnimeList API.", + tags = {"Catalog Proxy"}, + operationId = "searchMyAnimeListMangaDataByTitle") + @GetMapping("/proxy/myanimelist/{title}/search") + public DefaultResponseDTO> searchMyAnimeListMangaDataByTitle( + @PathVariable String title) { + var results = myAnimeListService.getMangasByTitle(title); + return DefaultResponseDTO.ok( + results.entrySet().stream() + .map( + mangaDataEntry -> + MangaProxyDataDTO.from(mangaDataEntry.getValue(), mangaDataEntry.getKey())) + .toList()); + } +} diff --git a/src/main/java/com/magamochi/catalog/model/dto/MangaProxyDataDTO.java b/src/main/java/com/magamochi/catalog/model/dto/MangaProxyDataDTO.java new file mode 100644 index 0000000..addea70 --- /dev/null +++ b/src/main/java/com/magamochi/catalog/model/dto/MangaProxyDataDTO.java @@ -0,0 +1,24 @@ +package com.magamochi.catalog.model.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.OffsetDateTime; +import java.util.List; + +public record MangaProxyDataDTO( + String imageUrl, + @NotBlank String title, + @NotNull List alternativeTitles, + @NotNull List authors, + OffsetDateTime publishedAt, + @NotNull Long id) { + public static MangaProxyDataDTO from(MangaDataDTO data, Long id) { + return new MangaProxyDataDTO( + data.coverImageUrl(), + data.title(), + data.alternativeTitles(), + data.authors(), + data.publishedFrom(), + id); + } +} diff --git a/src/main/java/com/magamochi/catalog/service/AniListService.java b/src/main/java/com/magamochi/catalog/service/AniListService.java index d00cc4d..8f5ffe9 100644 --- a/src/main/java/com/magamochi/catalog/service/AniListService.java +++ b/src/main/java/com/magamochi/catalog/service/AniListService.java @@ -52,46 +52,63 @@ public class AniListService { return Map.of(); } + public Map getMangasByTitle(String title) { + var request = getSearchGraphQLRequest(title); + + aniListRateLimiter.acquire(); + var response = aniListClient.searchManga(request); + + return response.data().page().media().stream() + .map(this::parseMangaData) + .flatMap(mangaDataMap -> mangaDataMap.entrySet().stream()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + public MangaDataDTO getMangaDataById(Long aniListId) { var request = getGraphQLRequest(aniListId); aniListRateLimiter.acquire(); - var media = aniListClient.getManga(request).data().Media(); + var manga = aniListClient.getManga(request).data().Media(); + return parseMangaData(manga).get(aniListId); + } + private Map parseMangaData(AniListClient.Manga manga) { var authors = - media.staff().edges().stream() + manga.staff().edges().stream() .filter(edge -> isAuthorRole(edge.role())) .map(edge -> edge.node().name().full()) .distinct() .toList(); - boolean hasRomajiTitle = nonNull(media.title().romaji()); - return MangaDataDTO.builder() - .title(hasRomajiTitle ? media.title().romaji() : media.title().english()) - .score( - nonNull(media.averageScore()) - ? DoubleUtil.round((double) media.averageScore() / 10, 2) - : 0) - .synopsis(media.description()) - .chapterCount(media.chapters()) - .publishedFrom(convertFuzzyDate(media.startDate())) - .publishedTo(convertFuzzyDate(media.endDate())) - .authors(authors) - .genres(media.genres()) - // TODO: improve this - .alternativeTitles( - hasRomajiTitle - ? nonNull(media.title().english()) - ? List.of(media.title().english(), media.title().nativeTitle()) - : List.of(media.title().nativeTitle()) - : List.of(media.title().nativeTitle())) - .coverImageUrl( - nonNull(media.coverImage().extraLarge()) - ? media.coverImage().extraLarge() - : media.coverImage().large()) - .status(mapStatus(media.status())) - .adult(nonNull(media.isAdult()) ? media.isAdult() : false) - .build(); + boolean hasRomajiTitle = nonNull(manga.title().romaji()); + return Map.of( + manga.id(), + MangaDataDTO.builder() + .title(hasRomajiTitle ? manga.title().romaji() : manga.title().english()) + .score( + nonNull(manga.averageScore()) + ? DoubleUtil.round((double) manga.averageScore() / 10, 2) + : 0) + .synopsis(manga.description()) + .chapterCount(manga.chapters()) + .publishedFrom(convertFuzzyDate(manga.startDate())) + .publishedTo(convertFuzzyDate(manga.endDate())) + .authors(authors) + .genres(manga.genres()) + // TODO: improve this + .alternativeTitles( + hasRomajiTitle + ? nonNull(manga.title().english()) + ? List.of(manga.title().english(), manga.title().nativeTitle()) + : List.of(manga.title().nativeTitle()) + : List.of(manga.title().nativeTitle())) + .coverImageUrl( + nonNull(manga.coverImage().extraLarge()) + ? manga.coverImage().extraLarge() + : manga.coverImage().large()) + .status(mapStatus(manga.status())) + .adult(nonNull(manga.isAdult()) ? manga.isAdult() : false) + .build()); } private static AniListClient.@NonNull GraphQLRequest getGraphQLRequest(Long aniListId) { @@ -144,6 +161,18 @@ public class AniListService { native } status + startDate { year month day } + coverImage { large, extraLarge } + staff { + edges { + role + node { + name { + full + } + } + } + } } } } diff --git a/src/main/java/com/magamochi/catalog/service/MyAnimeListService.java b/src/main/java/com/magamochi/catalog/service/MyAnimeListService.java index 57fa33e..809193b 100644 --- a/src/main/java/com/magamochi/catalog/service/MyAnimeListService.java +++ b/src/main/java/com/magamochi/catalog/service/MyAnimeListService.java @@ -34,11 +34,21 @@ public class MyAnimeListService { return results.stream() .collect( Collectors.toMap( - JikanClient.SearchResponse.MangaData::title, - JikanClient.SearchResponse.MangaData::mal_id, + JikanClient.MangaData::title, + JikanClient.MangaData::mal_id, (existing, second) -> existing)); } + public Map getMangasByTitle(String title) { + jikanRateLimiter.acquire(); + var response = jikanClient.mangaSearch(title).data(); + + return response.stream() + .map(this::parseMangaData) + .flatMap(mangaDataMap -> mangaDataMap.entrySet().stream()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + public MangaDataDTO getMangaDataById(Long malId) { jikanRateLimiter.acquire(); var response = jikanClient.getMangaById(malId); @@ -48,33 +58,32 @@ public class MyAnimeListService { } var responseData = response.data(); + return parseMangaData(responseData).get(malId); + } - var authors = - responseData.authors().stream() - .map(JikanClient.MangaResponse.MangaData.AuthorData::name) - .toList(); + private Map parseMangaData(JikanClient.MangaData data) { + var authors = data.authors().stream().map(JikanClient.MangaData.AuthorData::name).toList(); - var genres = - responseData.genres().stream() - .map(JikanClient.MangaResponse.MangaData.GenreData::name) - .toList(); + var genres = data.genres().stream().map(JikanClient.MangaData.GenreData::name).toList(); - var alternativeTitles = responseData.title_synonyms(); + var alternativeTitles = data.title_synonyms(); - return MangaDataDTO.builder() - .title(responseData.title()) - .score(nonNull(responseData.score()) ? DoubleUtil.round(responseData.score(), 2) : 0) - .synopsis(responseData.synopsis()) - .chapterCount(responseData.chapters()) - .publishedFrom(responseData.published().from()) - .publishedTo(responseData.published().to()) - .authors(authors) - .genres(genres) - .alternativeTitles(alternativeTitles) - .coverImageUrl(responseData.images().jpg().large_image_url()) - .status(mapStatus(responseData.status())) - .adult(nonNull(responseData.explicit_genres()) && !responseData.explicit_genres().isEmpty()) - .build(); + return Map.of( + data.mal_id(), + MangaDataDTO.builder() + .title(data.title()) + .score(nonNull(data.score()) ? DoubleUtil.round(data.score(), 2) : 0) + .synopsis(data.synopsis()) + .chapterCount(data.chapters()) + .publishedFrom(data.published().from()) + .publishedTo(data.published().to()) + .authors(authors) + .genres(genres) + .alternativeTitles(alternativeTitles) + .coverImageUrl(data.images().jpg().large_image_url()) + .status(mapStatus(data.status())) + .adult(nonNull(data.explicit_genres()) && !data.explicit_genres().isEmpty()) + .build()); } private MangaStatus mapStatus(String malStatus) {