From 245a35eb04c3e387a6fddab8a21a386d31e622c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Nov 2023 09:24:02 +0000 Subject: [PATCH 1/2] Bump org.codehaus.mojo:exec-maven-plugin from 3.1.0 to 3.1.1 Bumps [org.codehaus.mojo:exec-maven-plugin](https://github.com/mojohaus/exec-maven-plugin) from 3.1.0 to 3.1.1. - [Release notes](https://github.com/mojohaus/exec-maven-plugin/releases) - [Commits](https://github.com/mojohaus/exec-maven-plugin/compare/exec-maven-plugin-3.1.0...3.1.1) --- updated-dependencies: - dependency-name: org.codehaus.mojo:exec-maven-plugin dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c853f17..1e3f731 100644 --- a/pom.xml +++ b/pom.xml @@ -199,7 +199,7 @@ org.codehaus.mojo exec-maven-plugin - 3.1.0 + 3.1.1 ${main.class} From 2decfd56d47034402aa286cfa0c644a3c92ee232 Mon Sep 17 00:00:00 2001 From: Ziedelth Date: Tue, 10 Oct 2023 17:57:44 +0200 Subject: [PATCH 2/2] Introduce Profile entity and DTO alongside supporting components --- .gitignore | 1 + data/hibernate.cfg.xml | 2 + pom.xml | 54 ++- .../controllers/AbstractController.kt | 9 +- .../ziedelth/controllers/AnimeController.kt | 48 +- .../ziedelth/controllers/AyaneController.kt | 9 +- .../ziedelth/controllers/CountryController.kt | 11 +- .../ziedelth/controllers/EpisodeController.kt | 38 +- .../controllers/EpisodeTypeController.kt | 9 +- .../ziedelth/controllers/GenreController.kt | 9 +- .../controllers/LangTypeController.kt | 9 +- .../controllers/PlatformController.kt | 9 +- .../ziedelth/controllers/ProfileController.kt | 196 +++++++- .../ziedelth/converters/AbstractConverter.kt | 35 ++ .../profile/ProfileToProfileDtoConverter.kt | 20 + .../kotlin/fr/ziedelth/dtos/ProfileDto.kt | 13 + src/main/kotlin/fr/ziedelth/entities/Anime.kt | 27 +- .../kotlin/fr/ziedelth/entities/Country.kt | 2 +- .../kotlin/fr/ziedelth/entities/Episode.kt | 19 +- .../fr/ziedelth/entities/EpisodeType.kt | 2 +- src/main/kotlin/fr/ziedelth/entities/Genre.kt | 2 +- .../kotlin/fr/ziedelth/entities/LangType.kt | 2 +- .../kotlin/fr/ziedelth/entities/Platform.kt | 2 +- .../kotlin/fr/ziedelth/entities/Profile.kt | 36 ++ .../fr/ziedelth/entities/ProfileAnime.kt | 34 ++ .../fr/ziedelth/entities/ProfileEpisode.kt | 34 ++ src/main/kotlin/fr/ziedelth/plugins/HTTP.kt | 42 ++ .../kotlin/fr/ziedelth/plugins/Routing.kt | 190 ++++++-- .../repositories/AbstractRepository.kt | 22 +- .../ziedelth/repositories/AnimeRepository.kt | 36 +- .../repositories/EpisodeRepository.kt | 6 +- .../repositories/ProfileRepository.kt | 256 ++++++++++ src/main/kotlin/fr/ziedelth/utils/Constant.kt | 5 + src/main/kotlin/fr/ziedelth/utils/Database.kt | 46 +- .../fr/ziedelth/utils/routes/Authenticated.kt | 5 + .../fr/ziedelth/utils/routes/BodyParam.kt | 5 + .../fr/ziedelth/utils/routes/JWTUser.kt | 5 + .../fr/ziedelth/utils/routes/QueryParam.kt | 5 + .../fr/ziedelth/utils/routes/Response.kt | 3 +- .../controllers/EpisodeControllerTest.kt | 14 +- .../controllers/ProfileControllerTest.kt | 443 +++++++++++++++++- .../kotlin/fr/ziedelth/plugins/RoutingTest.kt | 1 + src/test/resources/hibernate.cfg.xml | 6 +- 43 files changed, 1514 insertions(+), 208 deletions(-) create mode 100644 src/main/kotlin/fr/ziedelth/converters/AbstractConverter.kt create mode 100644 src/main/kotlin/fr/ziedelth/converters/profile/ProfileToProfileDtoConverter.kt create mode 100644 src/main/kotlin/fr/ziedelth/dtos/ProfileDto.kt create mode 100644 src/main/kotlin/fr/ziedelth/entities/Profile.kt create mode 100644 src/main/kotlin/fr/ziedelth/entities/ProfileAnime.kt create mode 100644 src/main/kotlin/fr/ziedelth/entities/ProfileEpisode.kt create mode 100644 src/main/kotlin/fr/ziedelth/repositories/ProfileRepository.kt create mode 100644 src/main/kotlin/fr/ziedelth/utils/routes/Authenticated.kt create mode 100644 src/main/kotlin/fr/ziedelth/utils/routes/BodyParam.kt create mode 100644 src/main/kotlin/fr/ziedelth/utils/routes/JWTUser.kt create mode 100644 src/main/kotlin/fr/ziedelth/utils/routes/QueryParam.kt diff --git a/.gitignore b/.gitignore index 650cf52..aaf6a57 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ out/ /test.http **/firebase_key.json **/plugins/ +/data/logs/ diff --git a/data/hibernate.cfg.xml b/data/hibernate.cfg.xml index 151d10d..4ba082a 100644 --- a/data/hibernate.cfg.xml +++ b/data/hibernate.cfg.xml @@ -11,5 +11,7 @@ false update org.hibernate.context.internal.ThreadLocalSessionContext + true + jcache \ No newline at end of file diff --git a/pom.xml b/pom.xml index 1e3f731..d6a8aed 100644 --- a/pom.xml +++ b/pom.xml @@ -19,6 +19,7 @@ true fr.ziedelth.ApplicationKt 5.10.1 + 6.3.1.Final **/fr/ziedelth/dtos/**, @@ -107,15 +108,66 @@ ktor-server-caching-headers-jvm ${ktor_version} + + io.ktor + ktor-server-auth + ${ktor_version} + + + io.ktor + ktor-server-auth-jwt + ${ktor_version} + + + io.ktor + ktor-server-auth-jvm + ${ktor_version} + + + io.ktor + ktor-server-auth-jwt-jvm + ${ktor_version} + + + io.github.smiley4 + ktor-swagger-ui + 2.7.1 + ch.qos.logback logback-classic ${logback_version} + + com.auth0 + java-jwt + 4.4.0 + org.hibernate.orm hibernate-core - 6.3.1.Final + ${hibernate.version} + + + org.hibernate.orm + hibernate-jcache + ${hibernate.version} + + + org.ehcache + ehcache + 3.10.8 + + + org.glassfish.jaxb + jaxb-runtime + + + + + org.glassfish.jaxb + jaxb-runtime + 4.0.4 org.postgresql diff --git a/src/main/kotlin/fr/ziedelth/controllers/AbstractController.kt b/src/main/kotlin/fr/ziedelth/controllers/AbstractController.kt index 7c2c8d5..63c6a1a 100644 --- a/src/main/kotlin/fr/ziedelth/controllers/AbstractController.kt +++ b/src/main/kotlin/fr/ziedelth/controllers/AbstractController.kt @@ -11,6 +11,7 @@ const val UNKNOWN_MESSAGE_ERROR = "Unknown error" const val MISSING_PARAMETERS_MESSAGE_ERROR = "Missing parameters" open class AbstractController(open val prefix: String) { + @Deprecated("Please use JWT Tokens") data class FilterData( val animes: List = listOf(), // Animes in watchlist val episodes: List = listOf(), // Episodes seen @@ -18,9 +19,15 @@ open class AbstractController(open val prefix: String) { val langTypes: List = listOf(), // Lang types wanted to see ) - val entityName: String = ((javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class<*>).simpleName + val entityName: String = + ((javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class<*>).simpleName + @Deprecated("Please use JWT Tokens") fun decode(watchlist: String): FilterData { + if (watchlist.isBlank()) { + return FilterData() + } + val filterData = Constant.gson.fromJson(Decoder.fromGzip(watchlist), FilterData::class.java) Logger.config("Episodes: ${filterData.episodes.size} - Animes: ${filterData.animes.size}") return filterData diff --git a/src/main/kotlin/fr/ziedelth/controllers/AnimeController.kt b/src/main/kotlin/fr/ziedelth/controllers/AnimeController.kt index 5f9b7e4..036568c 100644 --- a/src/main/kotlin/fr/ziedelth/controllers/AnimeController.kt +++ b/src/main/kotlin/fr/ziedelth/controllers/AnimeController.kt @@ -12,6 +12,7 @@ import fr.ziedelth.services.EpisodeService import fr.ziedelth.utils.ImageCache import fr.ziedelth.utils.Logger import fr.ziedelth.utils.routes.Authorized +import fr.ziedelth.utils.routes.BodyParam import fr.ziedelth.utils.routes.Path import fr.ziedelth.utils.routes.Response import fr.ziedelth.utils.routes.method.Delete @@ -79,40 +80,41 @@ class AnimeController : AttachmentController("/animes") { @Path("/missing/page/{page}/limit/{limit}") @Post - private fun paginationMissing(body: String, page: Int, limit: Int): Response { - return Response.ok(animeRepository.getMissingAnimes(decode(body), page, limit)) + @Deprecated("Replaced by JWT at /profiles/missing/...") + private fun paginationMissing(@BodyParam watchlist: String, page: Int, limit: Int): Response { + return Response.ok(animeRepository.getMissingAnimes(decode(watchlist), page, limit)) } @Path @Post @Authorized - private fun save(body: Anime): Response { - val countryUuid = body.country!!.uuid - body.country = + private fun save(@BodyParam anime: Anime): Response { + val countryUuid = anime.country!!.uuid + anime.country = countryRepository.find(countryUuid) ?: return Response(HttpStatusCode.BadRequest, "Country not found") - val countryTag = body.country!!.tag!! + val countryTag = anime.country!!.tag!! - if (body.isNullOrNotValid()) { + if (anime.isNullOrNotValid()) { Logger.warning(MISSING_PARAMETERS_MESSAGE_ERROR) - Logger.warning(body.toString()) + Logger.warning(anime.toString()) return Response(HttpStatusCode.BadRequest, MISSING_PARAMETERS_MESSAGE_ERROR) } - if (animeRepository.findOneByName(countryTag, body.name!!)?.country?.uuid == countryUuid) { + if (animeRepository.findOneByName(countryTag, anime.name!!)?.country?.uuid == countryUuid) { Logger.warning("$entityName already exists") return Response(HttpStatusCode.Conflict, "$entityName already exists") } - val hash = body.hash() + val hash = anime.hash() if (animeRepository.findByHash(countryTag, hash) != null) { Logger.warning("$entityName already exists") return Response(HttpStatusCode.Conflict, "$entityName already exists") } - body.hashes.add(hash) - val savedAnime = animeRepository.save(body) + anime.hashes.add(hash) + val savedAnime = animeRepository.save(anime) ImageCache.cache(savedAnime.uuid, savedAnime.image!!) animeService.invalidateAll() return Response.created(savedAnime) @@ -121,24 +123,24 @@ class AnimeController : AttachmentController("/animes") { @Path @Put @Authorized - private fun update(body: Anime): Response { + private fun update(@BodyParam anime: Anime): Response { var savedAnime = - animeRepository.find(body.uuid) ?: return Response(HttpStatusCode.NotFound, ANIME_NOT_FOUND_ERROR) + animeRepository.find(anime.uuid) ?: return Response(HttpStatusCode.NotFound, ANIME_NOT_FOUND_ERROR) - if (!body.name.isNullOrBlank()) { - if (animeRepository.findOneByName(savedAnime.country!!.tag!!, body.name!!) != null) { + if (!anime.name.isNullOrBlank()) { + if (animeRepository.findOneByName(savedAnime.country!!.tag!!, anime.name!!) != null) { return Response(HttpStatusCode.Conflict, "Another anime with the name exist!") } - savedAnime.name = body.name + savedAnime.name = anime.name } - if (!body.description.isNullOrBlank()) { - savedAnime.description = body.description + if (!anime.description.isNullOrBlank()) { + savedAnime.description = anime.description } - if (body.simulcasts.isNotEmpty()) { - val savedSimulcasts = body.simulcasts.mapNotNull { simulcastRepository.find(it.uuid) } + if (anime.simulcasts.isNotEmpty()) { + val savedSimulcasts = anime.simulcasts.mapNotNull { simulcastRepository.find(it.uuid) } savedAnime.simulcasts.clear() savedAnime.simulcasts.addAll(savedSimulcasts) @@ -153,9 +155,9 @@ class AnimeController : AttachmentController("/animes") { @Path("/merge") @Put @Authorized - private fun merge(body: Array): Response { + private fun merge(@BodyParam animeIds: Array): Response { // Get anime - val animes = body.mapNotNull { animeRepository.find(it) } + val animes = animeIds.mapNotNull { animeRepository.find(it) } if (animes.isEmpty()) { Logger.warning(ANIME_NOT_FOUND_ERROR) diff --git a/src/main/kotlin/fr/ziedelth/controllers/AyaneController.kt b/src/main/kotlin/fr/ziedelth/controllers/AyaneController.kt index 5e566df..28dd0d4 100644 --- a/src/main/kotlin/fr/ziedelth/controllers/AyaneController.kt +++ b/src/main/kotlin/fr/ziedelth/controllers/AyaneController.kt @@ -4,6 +4,7 @@ import fr.ziedelth.dtos.AyaneDto import fr.ziedelth.events.AyaneReleaseEvent import fr.ziedelth.utils.plugins.PluginManager import fr.ziedelth.utils.routes.Authorized +import fr.ziedelth.utils.routes.BodyParam import fr.ziedelth.utils.routes.Path import fr.ziedelth.utils.routes.Response import fr.ziedelth.utils.routes.method.Post @@ -13,15 +14,15 @@ class AyaneController : AbstractController("/ayane") { @Path @Post @Authorized - private fun save(body: AyaneDto): Response { - if (body.message.isBlank() || body.images.isEmpty()) { + private fun save(@BodyParam ayaneDto: AyaneDto): Response { + if (ayaneDto.message.isBlank() || ayaneDto.images.isEmpty()) { return Response(HttpStatusCode.BadRequest, MISSING_PARAMETERS_MESSAGE_ERROR) } Thread { - PluginManager.callEvent(AyaneReleaseEvent(body)) + PluginManager.callEvent(AyaneReleaseEvent(ayaneDto)) }.start() - return Response.created(body) + return Response.created(ayaneDto) } } diff --git a/src/main/kotlin/fr/ziedelth/controllers/CountryController.kt b/src/main/kotlin/fr/ziedelth/controllers/CountryController.kt index 6f985af..ae7d026 100644 --- a/src/main/kotlin/fr/ziedelth/controllers/CountryController.kt +++ b/src/main/kotlin/fr/ziedelth/controllers/CountryController.kt @@ -6,6 +6,7 @@ import fr.ziedelth.entities.isNullOrNotValid import fr.ziedelth.repositories.CountryRepository import fr.ziedelth.services.CountryService import fr.ziedelth.utils.routes.Authorized +import fr.ziedelth.utils.routes.BodyParam import fr.ziedelth.utils.routes.Path import fr.ziedelth.utils.routes.Response import fr.ziedelth.utils.routes.method.Get @@ -28,20 +29,20 @@ class CountryController : AbstractController("/countries") { @Path @Post @Authorized - private fun save(body: Country): Response { - if (body.isNullOrNotValid()) { + private fun save(@BodyParam country: Country): Response { + if (country.isNullOrNotValid()) { return Response(HttpStatusCode.BadRequest, MISSING_PARAMETERS_MESSAGE_ERROR) } - if (countryRepository.exists("tag", body.tag)) { + if (countryRepository.exists("tag", country.tag)) { return Response(HttpStatusCode.Conflict, "$entityName already exists") } - if (countryRepository.exists("name", body.name)) { + if (countryRepository.exists("name", country.name)) { return Response(HttpStatusCode.Conflict, "$entityName already exists") } - val savedCountry = countryRepository.save(body) + val savedCountry = countryRepository.save(country) countryService.invalidateAll() return Response.created(savedCountry) } diff --git a/src/main/kotlin/fr/ziedelth/controllers/EpisodeController.kt b/src/main/kotlin/fr/ziedelth/controllers/EpisodeController.kt index 9d39c11..9356254 100644 --- a/src/main/kotlin/fr/ziedelth/controllers/EpisodeController.kt +++ b/src/main/kotlin/fr/ziedelth/controllers/EpisodeController.kt @@ -14,6 +14,7 @@ import fr.ziedelth.utils.ImageCache import fr.ziedelth.utils.SortType import fr.ziedelth.utils.plugins.PluginManager import fr.ziedelth.utils.routes.Authorized +import fr.ziedelth.utils.routes.BodyParam import fr.ziedelth.utils.routes.Path import fr.ziedelth.utils.routes.Response import fr.ziedelth.utils.routes.method.Get @@ -65,8 +66,8 @@ class EpisodeController : AttachmentController("/episodes") { @Path("/watchlist/page/{page}/limit/{limit}") @Post - private fun paginationWatchlist(body: String, page: Int, limit: Int): Response { - return Response.ok(episodeRepository.getByPageWithListFilter(decode(body), page, limit)) + private fun paginationWatchlist(@BodyParam watchlist: String, page: Int, limit: Int): Response { + return Response.ok(episodeRepository.getByPageWithListFilter(decode(watchlist), page, limit)) } private fun merge(episode: Episode) { @@ -126,8 +127,8 @@ class EpisodeController : AttachmentController("/episodes") { @Path("/multiple") @Post @Authorized - private fun saveMultiple(body: Array): Response { - val episodes = body.filter { !episodeRepository.exists("hash", it.hash!!) } + private fun saveMultiple(@BodyParam episodesToSave: Array): Response { + val episodes = episodesToSave.filter { !episodeRepository.exists("hash", it.hash!!) } if (episodes.isEmpty()) { return Response(HttpStatusCode.NoContent, "All requested episodes already exists!") @@ -158,25 +159,32 @@ class EpisodeController : AttachmentController("/episodes") { @Path @Put @Authorized - private fun update(body: Episode): Response { - var savedEpisode = episodeRepository.find(body.uuid) ?: return Response(HttpStatusCode.NotFound, "Episode not found") - - if (body.episodeType?.uuid != null) { - val foundEpisodeType = episodeTypeRepository.find(body.episodeType!!.uuid) ?: return Response(HttpStatusCode.NotFound, "Episode type not found") + private fun update(@BodyParam episode: Episode): Response { + var savedEpisode = + episodeRepository.find(episode.uuid) ?: return Response(HttpStatusCode.NotFound, "Episode not found") + + if (episode.episodeType?.uuid != null) { + val foundEpisodeType = episodeTypeRepository.find(episode.episodeType!!.uuid) ?: return Response( + HttpStatusCode.NotFound, + "Episode type not found" + ) savedEpisode.episodeType = foundEpisodeType } - if (body.langType?.uuid != null) { - val foundLangType = langTypeRepository.find(body.langType!!.uuid) ?: return Response(HttpStatusCode.NotFound, "Lang type not found") + if (episode.langType?.uuid != null) { + val foundLangType = langTypeRepository.find(episode.langType!!.uuid) ?: return Response( + HttpStatusCode.NotFound, + "Lang type not found" + ) savedEpisode.langType = foundLangType } - if (body.season != null) { - savedEpisode.season = body.season + if (episode.season != null) { + savedEpisode.season = episode.season } - if (body.duration != -1L) { - savedEpisode.duration = body.duration + if (episode.duration != -1L) { + savedEpisode.duration = episode.duration } savedEpisode = episodeRepository.save(savedEpisode) diff --git a/src/main/kotlin/fr/ziedelth/controllers/EpisodeTypeController.kt b/src/main/kotlin/fr/ziedelth/controllers/EpisodeTypeController.kt index 8502cfc..1da7d9d 100644 --- a/src/main/kotlin/fr/ziedelth/controllers/EpisodeTypeController.kt +++ b/src/main/kotlin/fr/ziedelth/controllers/EpisodeTypeController.kt @@ -6,6 +6,7 @@ import fr.ziedelth.entities.isNullOrNotValid import fr.ziedelth.repositories.EpisodeTypeRepository import fr.ziedelth.services.EpisodeTypeService import fr.ziedelth.utils.routes.Authorized +import fr.ziedelth.utils.routes.BodyParam import fr.ziedelth.utils.routes.Path import fr.ziedelth.utils.routes.Response import fr.ziedelth.utils.routes.method.Get @@ -28,16 +29,16 @@ class EpisodeTypeController : AbstractController("/episodetypes") { @Path @Post @Authorized - private fun save(body: EpisodeType): Response { - if (body.isNullOrNotValid()) { + private fun save(@BodyParam episodeType: EpisodeType): Response { + if (episodeType.isNullOrNotValid()) { return Response(HttpStatusCode.BadRequest, MISSING_PARAMETERS_MESSAGE_ERROR) } - if (episodeTypeRepository.exists("name", body.name)) { + if (episodeTypeRepository.exists("name", episodeType.name)) { return Response(HttpStatusCode.Conflict, "$entityName already exists") } - val savedEpisodeType = episodeTypeRepository.save(body) + val savedEpisodeType = episodeTypeRepository.save(episodeType) episodeTypeService.invalidateAll() return Response.created(savedEpisodeType) } diff --git a/src/main/kotlin/fr/ziedelth/controllers/GenreController.kt b/src/main/kotlin/fr/ziedelth/controllers/GenreController.kt index 53fd309..df5fd6f 100644 --- a/src/main/kotlin/fr/ziedelth/controllers/GenreController.kt +++ b/src/main/kotlin/fr/ziedelth/controllers/GenreController.kt @@ -5,6 +5,7 @@ import fr.ziedelth.entities.Genre import fr.ziedelth.entities.isNullOrNotValid import fr.ziedelth.repositories.GenreRepository import fr.ziedelth.utils.routes.Authorized +import fr.ziedelth.utils.routes.BodyParam import fr.ziedelth.utils.routes.Path import fr.ziedelth.utils.routes.Response import fr.ziedelth.utils.routes.method.Get @@ -24,15 +25,15 @@ class GenreController : AbstractController("/genres") { @Path @Post @Authorized - private fun save(body: Genre): Response { - if (body.isNullOrNotValid()) { + private fun save(@BodyParam genre: Genre): Response { + if (genre.isNullOrNotValid()) { return Response(HttpStatusCode.BadRequest, MISSING_PARAMETERS_MESSAGE_ERROR) } - if (genreRepository.exists("name", body.name)) { + if (genreRepository.exists("name", genre.name)) { return Response(HttpStatusCode.Conflict, "$entityName already exists") } - return Response.created(genreRepository.save(body)) + return Response.created(genreRepository.save(genre)) } } diff --git a/src/main/kotlin/fr/ziedelth/controllers/LangTypeController.kt b/src/main/kotlin/fr/ziedelth/controllers/LangTypeController.kt index 5c932cd..e9a81d0 100644 --- a/src/main/kotlin/fr/ziedelth/controllers/LangTypeController.kt +++ b/src/main/kotlin/fr/ziedelth/controllers/LangTypeController.kt @@ -6,6 +6,7 @@ import fr.ziedelth.entities.isNullOrNotValid import fr.ziedelth.repositories.LangTypeRepository import fr.ziedelth.services.LangTypeService import fr.ziedelth.utils.routes.Authorized +import fr.ziedelth.utils.routes.BodyParam import fr.ziedelth.utils.routes.Path import fr.ziedelth.utils.routes.Response import fr.ziedelth.utils.routes.method.Get @@ -28,16 +29,16 @@ class LangTypeController : AbstractController("/langtypes") { @Path @Post @Authorized - private fun save(body: LangType): Response { - if (body.isNullOrNotValid()) { + private fun save(@BodyParam langType: LangType): Response { + if (langType.isNullOrNotValid()) { return Response(HttpStatusCode.BadRequest, MISSING_PARAMETERS_MESSAGE_ERROR) } - if (langTypeRepository.exists("name", body.name)) { + if (langTypeRepository.exists("name", langType.name)) { return Response(HttpStatusCode.Conflict, "$entityName already exists") } - val savedLangType = langTypeRepository.save(body) + val savedLangType = langTypeRepository.save(langType) langTypeService.invalidateAll() return Response.created(savedLangType) } diff --git a/src/main/kotlin/fr/ziedelth/controllers/PlatformController.kt b/src/main/kotlin/fr/ziedelth/controllers/PlatformController.kt index ff48f59..7efc1bf 100644 --- a/src/main/kotlin/fr/ziedelth/controllers/PlatformController.kt +++ b/src/main/kotlin/fr/ziedelth/controllers/PlatformController.kt @@ -6,6 +6,7 @@ import fr.ziedelth.entities.isNullOrNotValid import fr.ziedelth.repositories.PlatformRepository import fr.ziedelth.utils.ImageCache import fr.ziedelth.utils.routes.Authorized +import fr.ziedelth.utils.routes.BodyParam import fr.ziedelth.utils.routes.Path import fr.ziedelth.utils.routes.Response import fr.ziedelth.utils.routes.method.Get @@ -25,16 +26,16 @@ class PlatformController : AttachmentController("/platforms") { @Path @Post @Authorized - private fun save(body: Platform): Response { - if (body.isNullOrNotValid()) { + private fun save(@BodyParam platform: Platform): Response { + if (platform.isNullOrNotValid()) { return Response(HttpStatusCode.BadRequest, MISSING_PARAMETERS_MESSAGE_ERROR) } - if (platformRepository.exists("name", body.name)) { + if (platformRepository.exists("name", platform.name)) { return Response(HttpStatusCode.Conflict, "$entityName already exists") } - val savedPlatform = platformRepository.save(body) + val savedPlatform = platformRepository.save(platform) ImageCache.cache(savedPlatform.uuid, savedPlatform.image!!) return Response.created(savedPlatform) } diff --git a/src/main/kotlin/fr/ziedelth/controllers/ProfileController.kt b/src/main/kotlin/fr/ziedelth/controllers/ProfileController.kt index 0662a48..1f5a86b 100644 --- a/src/main/kotlin/fr/ziedelth/controllers/ProfileController.kt +++ b/src/main/kotlin/fr/ziedelth/controllers/ProfileController.kt @@ -1,20 +1,206 @@ package fr.ziedelth.controllers +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm import com.google.inject.Inject +import fr.ziedelth.converters.AbstractConverter +import fr.ziedelth.dtos.ProfileDto +import fr.ziedelth.entities.Profile import fr.ziedelth.repositories.EpisodeRepository -import fr.ziedelth.utils.routes.Path -import fr.ziedelth.utils.routes.Response +import fr.ziedelth.repositories.ProfileRepository +import fr.ziedelth.services.EpisodeTypeService +import fr.ziedelth.services.LangTypeService +import fr.ziedelth.utils.Constant +import fr.ziedelth.utils.routes.* +import fr.ziedelth.utils.routes.method.Delete +import fr.ziedelth.utils.routes.method.Get import fr.ziedelth.utils.routes.method.Post +import fr.ziedelth.utils.routes.method.Put +import io.ktor.http.* import java.io.Serializable +import java.util.* class ProfileController : AbstractController("/profile") { @Inject private lateinit var episodeRepository: EpisodeRepository + @Inject + private lateinit var profileRepository: ProfileRepository + + @Inject + private lateinit var episodeTypeService: EpisodeTypeService + + @Inject + private lateinit var langTypeService: LangTypeService + + /** + * Calculates the total duration of episodes seen based on the provided watchlist. + * + * @param watchlist A string containing the watchlist data. + * @return A Response object containing the total duration of episodes seen in the watchlist. + * @deprecated Please use the method with JWT Tokens instead. + */ @Path("/total-duration") @Post - private fun getTotalDuration(body: String): Response { - val filterData = decode(body) - return Response.ok(mapOf("total-duration" to episodeRepository.getTotalDurationSeen(filterData.episodes))) + @Deprecated("Please use the method with JWT Tokens") + private fun getTotalDuration(@BodyParam watchlist: String): Response { + return Response.ok(mapOf("total-duration" to episodeRepository.getTotalDurationSeen(decode(watchlist).episodes))) + } + + /** + * Registers a watchlist. + * + * @param watchlist The watchlist to be registered. + * @return A Response object indicating the outcome of the registration process. + */ + @Path("/register") + @Post + private fun register(@BodyParam watchlist: String): Response { + return Response.ok(mapOf("tokenUuid" to profileRepository.save(decode(watchlist)).tokenUuid)) + } + + /** + * Logs in a user with the provided UUID string. + * + * @param uuid The UUID string used to identify the user. + * @return The response containing the profile information and access token if successful, or an error message if not found. + */ + @Path("/login") + @Post + private fun login(@BodyParam uuid: String): Response { + val loggedInProfile: Profile = profileRepository.findByToken(UUID.fromString(uuid)) ?: return Response( + HttpStatusCode.NotFound, + "Could not find profile" + ) + + val token = JWT.create() + .withAudience(Constant.jwtAudience) + .withIssuer(Constant.jwtDomain) + .withClaim("uuid", loggedInProfile.uuid.toString()) + .withExpiresAt(Date(System.currentTimeMillis() + Constant.JWT_TOKEN_TIMEOUT)) + .sign(Algorithm.HMAC256(Constant.jwtSecret)) + + val profileDto = AbstractConverter.convert(loggedInProfile, ProfileDto::class.java) + profileDto.token = token + return Response.ok(profileDto) + } + + /** + * Adds an anime or episode to the user's watchlist. + * + * @param jwtUser The user's JWT token. + * @param anime The name of the anime to add to the watchlist. + * @param episode The episode to add to the watchlist. (Optional) + * @return A response indicating the success or failure of the operation. + */ + @Path("/watchlist") + @Put + @Authenticated + private fun addToWatchlist(@JWTUser jwtUser: UUID, @QueryParam anime: String?, @QueryParam episode: String?): Response { + profileRepository.addToWatchlist(profileRepository.find(jwtUser)!!, anime, episode) ?: return Response(HttpStatusCode.BadRequest) + return Response.ok() + } + + /** + * Deletes an anime or episode from the user's watchlist. + * + * @param jwtUser The user's JWT that identifies the user. + * @param anime The name of the anime to delete from the watchlist. Optional parameter. + * @param episode The episode of the anime to delete from the watchlist. Optional parameter. + * @return A Response object indicating the status of the delete operation. + */ + @Path("/watchlist") + @Delete + @Authenticated + private fun deleteToWatchlist(@JWTUser jwtUser: UUID, @QueryParam anime: String?, @QueryParam episode: String?): Response { + profileRepository.removeToWatchlist(profileRepository.find(jwtUser)!!, anime, episode) ?: return Response(HttpStatusCode.BadRequest) + return Response.ok() + } + + private fun getEpisodeAndLangTypesUuid( + episodeTypes: String?, + langTypes: String? + ): Pair, List> { + val episodeTypesUuid = + if (episodeTypes.isNullOrBlank()) episodeTypeService.getAll().map { it.uuid } else episodeTypes.split(",").map { UUID.fromString(it) } + val langTypesUuid = if (langTypes.isNullOrBlank()) langTypeService.getAll().map { it.uuid } else langTypes.split(",").map { UUID.fromString(it) } + return Pair(episodeTypesUuid, langTypesUuid) + } + + /** + * Retrieves a paginated list of missing animes based on the specified filters. + * + * @param jwtUser The JWTUser object representing the authenticated user. + * @param episodeTypes A comma-separated string of episode types. If null or blank, all episode types will be considered. + * @param langTypes A comma-separated string of language types. If null or blank, all language types will be considered. + * @param page The page number of the desired results. + * @param limit The maximum number of results to be returned per page. + * + * @return A Response object containing the paginated list of missing animes. + */ + @Path("/watchlist/animes/missing/page/{page}/limit/{limit}") + @Get + @Authenticated + private fun paginationWatchlistAnimesMissing( + @JWTUser jwtUser: UUID, + @QueryParam episodeTypes: String?, + @QueryParam langTypes: String?, + page: Int, + limit: Int + ): Response { + val (episodeTypesUuid, langTypesUuid) = getEpisodeAndLangTypesUuid(episodeTypes, langTypes) + return Response.ok(profileRepository.getMissingAnimes(jwtUser, episodeTypesUuid, langTypesUuid, page, limit)) + } + + @Path("/watchlist/episodes/missing/page/{page}/limit/{limit}") + @Get + @Authenticated + private fun paginationWatchlistEpisodesMissing( + @JWTUser jwtUser: UUID, + @QueryParam episodeTypes: String?, + @QueryParam langTypes: String?, + page: Int, + limit: Int + ): Response { + val (episodeTypesUuid, langTypesUuid) = getEpisodeAndLangTypesUuid(episodeTypes, langTypes) + return Response.ok(profileRepository.getMissingEpisodes(jwtUser, episodeTypesUuid, langTypesUuid, page, limit)) + } + + /** + * Retrieves a paginated list of animes from the user's watchlist based on the specified page and limit. + * + * @param jwtUser The JWT user identifier. + * @param page The page number to retrieve. + * @param limit The maximum number of animes to retrieve per page. + * @return A Response object containing the paginated list of animes from the user's watchlist. + */ + @Path("/watchlist/animes/page/{page}/limit/{limit}") + @Get + @Authenticated + private fun getWatchlistAnimesByPageAndLimit(@JWTUser jwtUser: UUID, page: Int, limit: Int): Response { + return Response.ok(profileRepository.getWatchlistAnimes(jwtUser, page, limit)) + } + + /** + * Retrieves a specific page of episodes from the watchlist based on the given page number and limit. + * + * @param jwtUser The authenticated JWT user UUID. + * @param page The page number to retrieve. + * @param limit The maximum number of episodes per page. + * @return The response containing the requested episodes from the watchlist. + */ + @Path("/watchlist/episodes/page/{page}/limit/{limit}") + @Get + @Authenticated + private fun getWatchlistEpisodesByPageAndLimit(@JWTUser jwtUser: UUID, page: Int, limit: Int): Response { + return Response.ok(profileRepository.getWatchlistEpisodes(jwtUser, page, limit)) + } + + @Path + @Delete + @Authenticated + private fun deleteProfile(@JWTUser jwtUser: UUID): Response { + profileRepository.delete(profileRepository.find(jwtUser)!!) + return Response.ok() } } diff --git a/src/main/kotlin/fr/ziedelth/converters/AbstractConverter.kt b/src/main/kotlin/fr/ziedelth/converters/AbstractConverter.kt new file mode 100644 index 0000000..61cc7d2 --- /dev/null +++ b/src/main/kotlin/fr/ziedelth/converters/AbstractConverter.kt @@ -0,0 +1,35 @@ +package fr.ziedelth.converters + +import org.reflections.Reflections +import java.lang.reflect.ParameterizedType + +abstract class AbstractConverter { + abstract fun convert(from: F): T + + companion object { + private val converters: MutableMap, Class<*>>, AbstractConverter<*, *>> = mutableMapOf() + + init { + val converters = Reflections("fr.ziedelth.converters").getSubTypesOf(AbstractConverter::class.java) + + converters.forEach { + val (from, to) = (it.genericSuperclass as ParameterizedType).actualTypeArguments.map { argument -> argument as Class<*> } + this.converters[Pair(from, to)] = it.getConstructor().newInstance() + } + } + + fun convert(`object`: Any, to: Class): T { + val pair = Pair(`object`.javaClass, to) + + if (!converters.containsKey(pair)) { + throw NoSuchElementException("Can not find converter \"${`object`.javaClass.simpleName}\" to \"${to.simpleName}\"") + } + + val abstractConverter = converters[pair] ?: throw IllegalStateException() + val abstractConverterClass = abstractConverter.javaClass + val method = abstractConverterClass.getMethod("convert", `object`.javaClass) + method.isAccessible = true + return method.invoke(abstractConverter, `object`) as T + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/ziedelth/converters/profile/ProfileToProfileDtoConverter.kt b/src/main/kotlin/fr/ziedelth/converters/profile/ProfileToProfileDtoConverter.kt new file mode 100644 index 0000000..9826808 --- /dev/null +++ b/src/main/kotlin/fr/ziedelth/converters/profile/ProfileToProfileDtoConverter.kt @@ -0,0 +1,20 @@ +package fr.ziedelth.converters.profile + +import fr.ziedelth.converters.AbstractConverter +import fr.ziedelth.dtos.ProfileDto +import fr.ziedelth.entities.Profile + +class ProfileToProfileDtoConverter : AbstractConverter() { + override fun convert(from: Profile): ProfileDto { + val episodes = from.episodes.mapNotNull { it.episode } + + return ProfileDto( + from.uuid, + from.creationDate, + from.lastUpdate, + from.animes.map { it.anime!!.uuid }.toSet(), + episodes.map { it.uuid }.toSet(), + episodes.sumOf { if (it.duration > 0) it.duration else 0 }, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/ziedelth/dtos/ProfileDto.kt b/src/main/kotlin/fr/ziedelth/dtos/ProfileDto.kt new file mode 100644 index 0000000..b2f2aa7 --- /dev/null +++ b/src/main/kotlin/fr/ziedelth/dtos/ProfileDto.kt @@ -0,0 +1,13 @@ +package fr.ziedelth.dtos + +import java.util.* + +data class ProfileDto( + val uuid: UUID, + val creationDate: String, + val lastUpdate: String, + val animes: Set, + val episodes: Set, + val totalDurationSeen: Long, + var token: String? = null, +) diff --git a/src/main/kotlin/fr/ziedelth/entities/Anime.kt b/src/main/kotlin/fr/ziedelth/entities/Anime.kt index cff35cb..9e7bb79 100644 --- a/src/main/kotlin/fr/ziedelth/entities/Anime.kt +++ b/src/main/kotlin/fr/ziedelth/entities/Anime.kt @@ -8,7 +8,6 @@ import org.hibernate.annotations.CacheConcurrencyStrategy import java.io.Serializable import java.util.* -private const val COLLECTION_CACHE_REGION_NAME = "fr.ziedelth.entities.Anime" fun Anime?.isNullOrNotValid() = this == null || this.isNotValid() @Entity @@ -20,18 +19,18 @@ fun Anime?.isNullOrNotValid() = this == null || this.isNotValid() ] ) @Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) class Anime( @Id @GeneratedValue val uuid: UUID = UUID.randomUUID(), - @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.MERGE, CascadeType.PERSIST]) + @ManyToOne(cascade = [CascadeType.PERSIST, CascadeType.MERGE]) @JoinColumn( name = "country_uuid", nullable = false, foreignKey = ForeignKey(foreignKeyDefinition = "FOREIGN KEY (country_uuid) REFERENCES country (uuid) ON DELETE CASCADE") ) - @Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = COLLECTION_CACHE_REGION_NAME) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) var country: Country? = null, @Column(nullable = false) var name: String? = null, @@ -41,15 +40,15 @@ class Anime( val image: String? = null, @Column(nullable = true, columnDefinition = "TEXT") var description: String? = null, - @ElementCollection(fetch = FetchType.LAZY) + @ElementCollection(fetch = FetchType.EAGER) @CollectionTable( name = "anime_hash", joinColumns = [JoinColumn(name = "anime_uuid")], foreignKey = ForeignKey(foreignKeyDefinition = "FOREIGN KEY (anime_uuid) REFERENCES anime (uuid) ON DELETE CASCADE") ) - @Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = COLLECTION_CACHE_REGION_NAME) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) val hashes: MutableSet = mutableSetOf(), - @ManyToMany(fetch = FetchType.LAZY, cascade = [CascadeType.MERGE, CascadeType.PERSIST]) + @ManyToMany(fetch = FetchType.EAGER, cascade = [CascadeType.PERSIST, CascadeType.MERGE]) @JoinTable( name = "anime_genre", joinColumns = [ @@ -63,11 +62,15 @@ class Anime( name = "genre_uuid", foreignKey = ForeignKey(foreignKeyDefinition = "FOREIGN KEY (genre_uuid) REFERENCES genre (uuid) ON DELETE CASCADE") ) + ], + indexes = [ + Index(name = "index_anime_genre_anime_uuid", columnList = "anime_uuid"), + Index(name = "index_anime_genre_genre_uuid", columnList = "genre_uuid"), ] ) - @Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = COLLECTION_CACHE_REGION_NAME) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) val genres: MutableSet = mutableSetOf(), - @ManyToMany(fetch = FetchType.LAZY, cascade = [CascadeType.MERGE, CascadeType.PERSIST]) + @ManyToMany(fetch = FetchType.EAGER, cascade = [CascadeType.PERSIST, CascadeType.MERGE]) @JoinTable( name = "anime_simulcast", joinColumns = [ @@ -81,9 +84,13 @@ class Anime( name = "simulcast_uuid", foreignKey = ForeignKey(foreignKeyDefinition = "FOREIGN KEY (simulcast_uuid) REFERENCES simulcast (uuid) ON DELETE CASCADE") ) + ], + indexes = [ + Index(name = "index_anime_simulcast_anime_uuid", columnList = "anime_uuid"), + Index(name = "index_anime_simulcast_simulcast_uuid", columnList = "simulcast_uuid"), ] ) - @Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = COLLECTION_CACHE_REGION_NAME) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) val simulcasts: MutableSet = mutableSetOf(), ) : Serializable { fun hash(): String = name!!.lowercase().filter { it.isLetterOrDigit() || it.isWhitespace() || it == '-' }.trim() diff --git a/src/main/kotlin/fr/ziedelth/entities/Country.kt b/src/main/kotlin/fr/ziedelth/entities/Country.kt index 94e2240..9e2aa74 100644 --- a/src/main/kotlin/fr/ziedelth/entities/Country.kt +++ b/src/main/kotlin/fr/ziedelth/entities/Country.kt @@ -11,7 +11,7 @@ fun Country?.isNullOrNotValid() = this == null || this.isNotValid() @Entity @Table(name = "country") @Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) class Country( @Id @GeneratedValue diff --git a/src/main/kotlin/fr/ziedelth/entities/Episode.kt b/src/main/kotlin/fr/ziedelth/entities/Episode.kt index 2129998..4b03189 100644 --- a/src/main/kotlin/fr/ziedelth/entities/Episode.kt +++ b/src/main/kotlin/fr/ziedelth/entities/Episode.kt @@ -8,7 +8,6 @@ import org.hibernate.annotations.CacheConcurrencyStrategy import java.io.Serializable import java.util.* -private const val COLLECTION_CACHE_REGION_NAME = "fr.ziedelth.entities.Episode" fun Episode?.isNullOrNotValid() = this == null || this.isNotValid() @Entity @@ -23,42 +22,42 @@ fun Episode?.isNullOrNotValid() = this == null || this.isNotValid() ] ) @Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) class Episode( @Id @GeneratedValue val uuid: UUID = UUID.randomUUID(), - @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @ManyToOne(cascade = [CascadeType.ALL]) @JoinColumn( name = "platform_uuid", nullable = false, foreignKey = ForeignKey(foreignKeyDefinition = "FOREIGN KEY (platform_uuid) REFERENCES platform(uuid) ON DELETE CASCADE") ) - @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) var platform: Platform? = null, - @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @ManyToOne(cascade = [CascadeType.ALL]) @JoinColumn( name = "anime_uuid", nullable = false, foreignKey = ForeignKey(foreignKeyDefinition = "FOREIGN KEY (anime_uuid) REFERENCES anime(uuid) ON DELETE CASCADE") ) - @Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = COLLECTION_CACHE_REGION_NAME) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) var anime: Anime? = null, - @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @ManyToOne(cascade = [CascadeType.ALL]) @JoinColumn( name = "episode_type_uuid", nullable = false, foreignKey = ForeignKey(foreignKeyDefinition = "FOREIGN KEY (episode_type_uuid) REFERENCES episodetype(uuid) ON DELETE CASCADE") ) - @Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = COLLECTION_CACHE_REGION_NAME) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) var episodeType: EpisodeType? = null, - @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @ManyToOne(cascade = [CascadeType.ALL]) @JoinColumn( name = "lang_type_uuid", nullable = false, foreignKey = ForeignKey(foreignKeyDefinition = "FOREIGN KEY (lang_type_uuid) REFERENCES langtype(uuid) ON DELETE CASCADE") ) - @Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = COLLECTION_CACHE_REGION_NAME) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) var langType: LangType? = null, @Column(nullable = false, unique = true) val hash: String? = null, diff --git a/src/main/kotlin/fr/ziedelth/entities/EpisodeType.kt b/src/main/kotlin/fr/ziedelth/entities/EpisodeType.kt index da80506..c399bad 100644 --- a/src/main/kotlin/fr/ziedelth/entities/EpisodeType.kt +++ b/src/main/kotlin/fr/ziedelth/entities/EpisodeType.kt @@ -11,7 +11,7 @@ fun EpisodeType?.isNullOrNotValid() = this == null || this.isNotValid() @Entity @Table(name = "episodetype") @Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) class EpisodeType( @Id @GeneratedValue diff --git a/src/main/kotlin/fr/ziedelth/entities/Genre.kt b/src/main/kotlin/fr/ziedelth/entities/Genre.kt index a1ec24a..d1b857e 100644 --- a/src/main/kotlin/fr/ziedelth/entities/Genre.kt +++ b/src/main/kotlin/fr/ziedelth/entities/Genre.kt @@ -11,7 +11,7 @@ fun Genre?.isNullOrNotValid() = this == null || this.isNotValid() @Entity @Table(name = "genre") @Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) class Genre( @Id @GeneratedValue diff --git a/src/main/kotlin/fr/ziedelth/entities/LangType.kt b/src/main/kotlin/fr/ziedelth/entities/LangType.kt index 131a4de..e2686ab 100644 --- a/src/main/kotlin/fr/ziedelth/entities/LangType.kt +++ b/src/main/kotlin/fr/ziedelth/entities/LangType.kt @@ -11,7 +11,7 @@ fun LangType?.isNullOrNotValid() = this == null || this.isNotValid() @Entity @Table(name = "langtype") @Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) class LangType( @Id @GeneratedValue diff --git a/src/main/kotlin/fr/ziedelth/entities/Platform.kt b/src/main/kotlin/fr/ziedelth/entities/Platform.kt index bae4cbe..4be3854 100644 --- a/src/main/kotlin/fr/ziedelth/entities/Platform.kt +++ b/src/main/kotlin/fr/ziedelth/entities/Platform.kt @@ -11,7 +11,7 @@ fun Platform?.isNullOrNotValid() = this == null || this.isNotValid() @Entity @Table(name = "platform") @Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) class Platform( @Id @GeneratedValue diff --git a/src/main/kotlin/fr/ziedelth/entities/Profile.kt b/src/main/kotlin/fr/ziedelth/entities/Profile.kt new file mode 100644 index 0000000..8c73d48 --- /dev/null +++ b/src/main/kotlin/fr/ziedelth/entities/Profile.kt @@ -0,0 +1,36 @@ +package fr.ziedelth.entities + +import fr.ziedelth.utils.toISO8601 +import jakarta.persistence.* +import org.hibernate.annotations.Cache +import org.hibernate.annotations.CacheConcurrencyStrategy +import java.io.Serializable +import java.util.* + +@Entity +@Table( + name = "profile", indexes = [ + Index(name = "index_profile_token_uuid", columnList = "token_uuid", unique = true), + ] +) +@Cacheable +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +class Profile( + @Id + @GeneratedValue + val uuid: UUID = UUID.randomUUID(), + @Column(name = "token_uuid", nullable = false, unique = true) + val tokenUuid: UUID = UUID.randomUUID(), + @Column(nullable = false) + val creationDate: String = Calendar.getInstance().toISO8601(), + @Column(nullable = false) + var lastUpdate: String = Calendar.getInstance().toISO8601(), + @OneToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL], orphanRemoval = true, mappedBy = "profile") + @OrderBy("add_date") + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + val animes: MutableSet = mutableSetOf(), + @OneToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL], orphanRemoval = true, mappedBy = "profile") + @OrderBy("add_date") + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + val episodes: MutableSet = mutableSetOf(), +) : Serializable \ No newline at end of file diff --git a/src/main/kotlin/fr/ziedelth/entities/ProfileAnime.kt b/src/main/kotlin/fr/ziedelth/entities/ProfileAnime.kt new file mode 100644 index 0000000..3530b0e --- /dev/null +++ b/src/main/kotlin/fr/ziedelth/entities/ProfileAnime.kt @@ -0,0 +1,34 @@ +package fr.ziedelth.entities + +import fr.ziedelth.utils.toISO8601 +import jakarta.persistence.* +import org.hibernate.annotations.Cache +import org.hibernate.annotations.CacheConcurrencyStrategy +import java.io.Serializable +import java.util.* + +@Entity +@Table(name = "profile_anime") +class ProfileAnime( + @Id + @GeneratedValue + val uuid: UUID = UUID.randomUUID(), + @ManyToOne + @JoinColumn( + name = "profile_uuid", + nullable = false, + foreignKey = ForeignKey(foreignKeyDefinition = "FOREIGN KEY (profile_uuid) REFERENCES profile (uuid) ON DELETE CASCADE") + ) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + val profile: Profile? = null, + @ManyToOne + @JoinColumn( + name = "anime_uuid", + nullable = false, + foreignKey = ForeignKey(foreignKeyDefinition = "FOREIGN KEY (anime_uuid) REFERENCES anime (uuid) ON DELETE CASCADE") + ) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + val anime: Anime? = null, + @Column(nullable = false, name = "add_date") + val addDate: String = Calendar.getInstance().toISO8601(), +) : Serializable \ No newline at end of file diff --git a/src/main/kotlin/fr/ziedelth/entities/ProfileEpisode.kt b/src/main/kotlin/fr/ziedelth/entities/ProfileEpisode.kt new file mode 100644 index 0000000..529cb4d --- /dev/null +++ b/src/main/kotlin/fr/ziedelth/entities/ProfileEpisode.kt @@ -0,0 +1,34 @@ +package fr.ziedelth.entities + +import fr.ziedelth.utils.toISO8601 +import jakarta.persistence.* +import org.hibernate.annotations.Cache +import org.hibernate.annotations.CacheConcurrencyStrategy +import java.io.Serializable +import java.util.* + +@Entity +@Table(name = "profile_episode") +class ProfileEpisode( + @Id + @GeneratedValue + val uuid: UUID = UUID.randomUUID(), + @ManyToOne + @JoinColumn( + name = "profile_uuid", + nullable = false, + foreignKey = ForeignKey(foreignKeyDefinition = "FOREIGN KEY (profile_uuid) REFERENCES profile (uuid) ON DELETE CASCADE") + ) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + val profile: Profile? = null, + @ManyToOne + @JoinColumn( + name = "episode_uuid", + nullable = false, + foreignKey = ForeignKey(foreignKeyDefinition = "FOREIGN KEY (episode_uuid) REFERENCES episode (uuid) ON DELETE CASCADE") + ) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + val episode: Episode? = null, + @Column(nullable = false, name = "add_date") + val addDate: String = Calendar.getInstance().toISO8601(), +) : Serializable \ No newline at end of file diff --git a/src/main/kotlin/fr/ziedelth/plugins/HTTP.kt b/src/main/kotlin/fr/ziedelth/plugins/HTTP.kt index 1d965ce..96510c7 100644 --- a/src/main/kotlin/fr/ziedelth/plugins/HTTP.kt +++ b/src/main/kotlin/fr/ziedelth/plugins/HTTP.kt @@ -1,11 +1,18 @@ package fr.ziedelth.plugins +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import fr.ziedelth.utils.Constant +import io.github.smiley4.ktorswaggerui.SwaggerUI import io.ktor.http.* import io.ktor.serialization.gson.* import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* import io.ktor.server.plugins.compression.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.response.* fun Application.configureHTTP() { install(Compression) { @@ -28,4 +35,39 @@ fun Application.configureHTTP() { gson { } } + + authentication { + jwt("auth-jwt") { + realm = Constant.jwtRealm + verifier( + JWT.require(Algorithm.HMAC256(Constant.jwtSecret)) + .withAudience(Constant.jwtAudience) + .withIssuer(Constant.jwtDomain) + .build() + ) + validate { credential -> + if (credential.payload.audience.contains(Constant.jwtAudience) && !credential.payload.getClaim("uuid") + ?.asString().isNullOrBlank() + ) + JWTPrincipal(credential.payload) + else + null + } + challenge { _, _ -> + call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired") + } + } + } + + install(SwaggerUI) { + swagger { + swaggerUrl = "swagger" + forwardRoot = true + } + info { + title = "API" + version = "latest" + description = "API for testing and demonstration purposes." + } + } } diff --git a/src/main/kotlin/fr/ziedelth/plugins/Routing.kt b/src/main/kotlin/fr/ziedelth/plugins/Routing.kt index 6be6a37..e90d7c4 100644 --- a/src/main/kotlin/fr/ziedelth/plugins/Routing.kt +++ b/src/main/kotlin/fr/ziedelth/plugins/Routing.kt @@ -6,6 +6,7 @@ import com.google.inject.Injector import fr.ziedelth.controllers.AbstractController import fr.ziedelth.controllers.AttachmentController import fr.ziedelth.dtos.AyaneDto +import fr.ziedelth.dtos.ProfileDto import fr.ziedelth.entities.* import fr.ziedelth.entities.Platform import fr.ziedelth.repositories.AbstractRepository @@ -18,9 +19,15 @@ import fr.ziedelth.utils.routes.method.Delete import fr.ziedelth.utils.routes.method.Get import fr.ziedelth.utils.routes.method.Post import fr.ziedelth.utils.routes.method.Put +import io.github.smiley4.ktorswaggerui.dsl.delete +import io.github.smiley4.ktorswaggerui.dsl.get +import io.github.smiley4.ktorswaggerui.dsl.post +import io.github.smiley4.ktorswaggerui.dsl.put import io.ktor.http.* import io.ktor.http.content.* import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* import io.ktor.server.plugins.cachingheaders.* import io.ktor.server.request.* import io.ktor.server.response.* @@ -50,6 +57,11 @@ class DatabaseModule(private val reflections: Reflections, private val database: } } +/** + * Configures the routing for the application using the provided database. + * + * @param database The database to be used. + */ fun Application.configureRouting(database: Database) { val reflections = Reflections("fr.ziedelth") val injector = Guice.createInjector(DatabaseModule(reflections, database)) @@ -59,6 +71,12 @@ fun Application.configureRouting(database: Database) { } } +/** + * Creates routes for all controllers found in the given Reflections instance. + * + * @param reflections The Reflections instance used to find controller classes. + * @param injector The Injector instance used to create controller instances. + */ private fun Routing.createRoutes(reflections: Reflections, injector: Injector) { reflections.getSubTypesOf(AbstractController::class.java).forEach { controllerClass -> if (controllerClass.isAnnotationPresent(IgnorePath::class.java)) { @@ -70,6 +88,13 @@ private fun Routing.createRoutes(reflections: Reflections, injector: Injector) { } } +/** + * Checks if the given method is authorized based on the presence of the @Authorized annotation and the secure key. + * + * @param method The Kotlin function being checked for authorization. + * @param call The application call object representing the current request. + * @return `true` if the method is authorized, otherwise `false`. + */ private suspend fun isAuthorized(method: KFunction<*>, call: ApplicationCall): Boolean { if (method.hasAnnotation() && !Constant.secureKey.isNullOrBlank()) { val authorization = call.request.headers[HttpHeaders.Authorization] @@ -84,6 +109,15 @@ private suspend fun isAuthorized(method: KFunction<*>, call: ApplicationCall): B return true } +/** + * Handles an HTTP request. + * + * @param httpMethod The HTTP method of the request. + * @param call The ApplicationCall object representing the request. + * @param method The Kotlin function representing the request handler. + * @param controller The instance of the controller class that contains the request handler. + * @param path The path of the request. + */ private suspend fun handleRequest( httpMethod: String, call: ApplicationCall, @@ -112,6 +146,11 @@ private suspend fun handleRequest( } } +/** + * Creates routes for a controller. + * + * @param controller The controller object to create routes for. + */ fun Routing.createControllerRoutes(controller: AbstractController<*>) { val isAttachmentController = controller::class.isSubclassOf(AttachmentController::class) val kMethods = controller::class.declaredFunctions.filter { it.hasAnnotation() }.toMutableSet() @@ -132,38 +171,85 @@ fun Routing.createControllerRoutes(controller: AbstractController<*>) { } } - if (method.hasAnnotation()) { - get(path) { - handleRequest("GET", call, method, controller, path) + if (method.hasAnnotation()) { + authenticate("auth-jwt") { + handleMethods(method, path, controller) } + } else { + handleMethods(method, path, controller) } + } + } +} - if (method.hasAnnotation()) { - post(path) { - handleRequest("POST", call, method, controller, path) - } - } +/** + * Registers a route handler for the given HTTP methods. + * + * @param method The method to handle (e.g. GET, POST, PUT, DELETE). + * @param path The route path to match. + * @param controller The controller to handle the request. + */ +private fun Route.handleMethods( + method: KFunction<*>, + path: String, + controller: AbstractController<*> +) { + val tag = controller.javaClass.simpleName.replace("Controller", "") - if (method.hasAnnotation()) { - put(path) { - handleRequest("PUT", call, method, controller, path) - } - } + if (method.hasAnnotation()) { + get(path, { + tags = listOf(tag) + }) { + handleRequest("GET", call, method, controller, path) + } + } - if (method.hasAnnotation()) { - delete(path) { - handleRequest("DELETE", call, method, controller, path) - } - } + if (method.hasAnnotation()) { + post(path, { + tags = listOf(tag) + }) { + handleRequest("POST", call, method, controller, path) + } + } + + if (method.hasAnnotation()) { + put(path, { + tags = listOf(tag) + }) { + handleRequest("PUT", call, method, controller, path) + } + } + + if (method.hasAnnotation()) { + delete(path, { + tags = listOf(tag) + }) { + handleRequest("DELETE", call, method, controller, path) } } } +/** + * Replaces path placeholders with the provided parameter values. + * + * @param path The original path string with placeholders. + * @param parameters A map containing parameter names and corresponding values. + * @return The updated path string with replaced placeholders. + */ private fun replacePathWithParameters(path: String, parameters: Map>): String = parameters.keys.fold(path) { acc, param -> acc.replace("{$param}", parameters[param]!!.joinToString(", ")) } +/** + * Calls a method with the given parameters. + * + * @param method The method to be called. + * @param controller The controller object on which the method is called. + * @param call The application call. + * @param parameters The parameters for the method call. + * @return The response from the method call. + */ private suspend fun callMethodWithParameters( method: KFunction<*>, controller: AbstractController<*>, @@ -171,39 +257,55 @@ private suspend fun callMethodWithParameters( parameters: Map> ): Response { val methodParams = method.parameters.associateWith { kParameter -> - if (kParameter.name.isNullOrBlank()) { - controller - } else if (kParameter.name == "body") { - when (kParameter.type.javaType) { - Anime::class.java -> call.receive() - Array::class.java -> call.receive>() - AyaneDto::class.java -> call.receive() - Country::class.java -> call.receive() - Array::class.java -> call.receive>() - EpisodeType::class.java -> call.receive() - Genre::class.java -> call.receive() - LangType::class.java -> call.receive() - Platform::class.java -> call.receive() - else -> call.receive() + when { + kParameter.name.isNullOrBlank() -> { + controller } - } else { - val value = parameters[kParameter.name]!!.first() - val parsedValue = when (kParameter.type.javaType) { - UUID::class.java -> UUID.fromString(value) - Int::class.java -> value.toInt() - else -> value + method.hasAnnotation() && kParameter.hasAnnotation() -> { + val jwtPrincipal = call.principal() + UUID.fromString(jwtPrincipal!!.payload.getClaim("uuid").asString()) } - when (kParameter.name) { - "page" -> require(parsedValue as Int >= 1) { "Page is not valid" } - "limit" -> { - val i = parsedValue as Int - require(i in 1..30) { "Limit is not valid" } + kParameter.hasAnnotation() -> { + when (kParameter.type.javaType) { + Anime::class.java -> call.receive() + Array::class.java -> call.receive>() + AyaneDto::class.java -> call.receive() + Country::class.java -> call.receive() + Array::class.java -> call.receive>() + EpisodeType::class.java -> call.receive() + Genre::class.java -> call.receive() + LangType::class.java -> call.receive() + Platform::class.java -> call.receive() + ProfileDto::class.java -> call.receive() + else -> call.receive() } } - parsedValue + kParameter.hasAnnotation() -> { + call.request.queryParameters[kParameter.name!!] + } + + else -> { + val value = parameters[kParameter.name]!!.first() + + val parsedValue: Any = when (kParameter.type.javaType) { + UUID::class.java -> UUID.fromString(value) + Int::class.java -> value.toInt() + else -> value + } + + when (kParameter.name) { + "page" -> require(parsedValue as Int >= 1) { "Page is not valid" } + "limit" -> { + val i = parsedValue as Int + require(i in 1..30) { "Limit is not valid" } + } + } + + parsedValue + } } } diff --git a/src/main/kotlin/fr/ziedelth/repositories/AbstractRepository.kt b/src/main/kotlin/fr/ziedelth/repositories/AbstractRepository.kt index 49b95da..656d86c 100644 --- a/src/main/kotlin/fr/ziedelth/repositories/AbstractRepository.kt +++ b/src/main/kotlin/fr/ziedelth/repositories/AbstractRepository.kt @@ -14,11 +14,11 @@ open class AbstractRepository { private val entityName: String = entityClass.simpleName fun find(uuid: UUID): T? { - return database.inTransaction { database.fullInitialize(it.find(entityClass, uuid)) } + return database.inReadOnlyTransaction { it.find(entityClass, uuid) } } fun exists(field: String, value: Any?): Boolean { - return database.inTransaction { + return database.inReadOnlyTransaction { val query = it.createQuery("SELECT uuid FROM $entityName WHERE $field = :$field", UUID::class.java) query.maxResults = 1 query.setParameter(field, value) @@ -26,8 +26,8 @@ open class AbstractRepository { } != null } - fun findAll(uuids: List): List { - return database.inTransaction { + fun findAll(uuids: Collection): List { + return database.inReadOnlyTransaction { it.createQuery("FROM $entityName WHERE uuid IN :uuids", entityClass) .setParameter("uuids", uuids) .resultList @@ -35,18 +35,14 @@ open class AbstractRepository { } fun getAll(): MutableList { - return database.inTransaction { - database.fullInitialize( - it.createQuery("FROM $entityName", entityClass).list() - ) - } + return database.inReadOnlyTransaction { it.createQuery("FROM $entityName", entityClass).resultList } } fun getAllBy(field: String, value: Any?): MutableList { - return database.inTransaction { + return database.inReadOnlyTransaction { val query = it.createQuery("FROM $entityName WHERE $field = :value", entityClass) query.setParameter("value", value) - query.list() + query.resultList } } @@ -85,12 +81,12 @@ open class AbstractRepository { queryRaw: String, vararg pair: Pair? ): List { - return database.inTransaction { + return database.inReadOnlyTransaction { val query = it.createQuery(queryRaw, clazz) pair.forEach { param -> if (param != null) query.setParameter(param.first, param.second) } query.firstResult = (limit * page) - limit query.maxResults = limit - database.fullInitialize(query.list()) + query.list() } } diff --git a/src/main/kotlin/fr/ziedelth/repositories/AnimeRepository.kt b/src/main/kotlin/fr/ziedelth/repositories/AnimeRepository.kt index ac6d194..5c980ea 100644 --- a/src/main/kotlin/fr/ziedelth/repositories/AnimeRepository.kt +++ b/src/main/kotlin/fr/ziedelth/repositories/AnimeRepository.kt @@ -12,7 +12,7 @@ import java.util.* class AnimeRepository : AbstractRepository() { fun findByHash(tag: String, hash: String): UUID? { - return database.inTransaction { + return database.inReadOnlyTransaction { val query = it.createQuery( "SELECT a.uuid FROM Anime a JOIN a.hashes h WHERE a.country.tag = :tag AND h = :hash", UUID::class.java @@ -25,7 +25,7 @@ class AnimeRepository : AbstractRepository() { } fun findOneByName(tag: String, name: String): Anime? { - return database.inTransaction { + return database.inReadOnlyTransaction { val query = it.createQuery( "FROM Anime WHERE country.tag = :tag AND LOWER(name) = :name", Anime::class.java @@ -33,20 +33,20 @@ class AnimeRepository : AbstractRepository() { query.maxResults = 1 query.setParameter("tag", tag) query.setParameter("name", name.lowercase()) - database.fullInitialize(query.uniqueResult()) + query.uniqueResult() } } // CREATE EXTENSION unaccent fun findByName(tag: String, name: String): List { - return database.inTransaction { + return database.inReadOnlyTransaction { val query = it.createQuery( "SELECT DISTINCT anime FROM Episode e WHERE e.anime.country.tag = :tag AND LOWER(FUNCTION('unaccent', 'unaccent', e.anime.name)) LIKE CONCAT('%', :name, '%') ORDER BY e.anime.name", Anime::class.java ) query.setParameter("tag", tag) query.setParameter("name", name.unaccent().lowercase()) - database.fullInitialize(query.list()) + query.list() } } @@ -61,7 +61,7 @@ class AnimeRepository : AbstractRepository() { } fun getDiary(tag: String, day: Int): List { - return database.inTransaction { session -> + return database.inReadOnlyTransaction { session -> val query = session.createQuery( "SELECT episode FROM Episode episode WHERE episode.anime.country.tag = :tag ORDER BY episode.releaseDate DESC LIMIT 100", Episode::class.java @@ -69,17 +69,15 @@ class AnimeRepository : AbstractRepository() { query.setParameter("tag", tag) val list = query.list() - database.fullInitialize( - list.filter { - OffsetDateTime.parse( - it.releaseDate, - DateTimeFormatter.ISO_OFFSET_DATE_TIME - ).dayOfWeek == DayOfWeek.of(day) - } - .sortedBy { OffsetDateTime.parse(it.releaseDate, DateTimeFormatter.ISO_OFFSET_DATE_TIME) } - .mapNotNull { it.anime } - .distinctBy { it.uuid } - ) + list.filter { + OffsetDateTime.parse( + it.releaseDate, + DateTimeFormatter.ISO_OFFSET_DATE_TIME + ).dayOfWeek == DayOfWeek.of(day) + } + .sortedBy { OffsetDateTime.parse(it.releaseDate, DateTimeFormatter.ISO_OFFSET_DATE_TIME) } + .mapNotNull { it.anime } + .distinctBy { it.uuid } } } @@ -132,13 +130,13 @@ class AnimeRepository : AbstractRepository() { } fun getInvalidAnimes(tag: String): List { - return database.inTransaction { session -> + return database.inReadOnlyTransaction { session -> val query = session.createQuery( "SELECT anime FROM Anime anime WHERE anime.country.tag = :tag AND (anime.description LIKE '(%' OR anime.description IS NULL OR TRIM(anime.description) = '')", Anime::class.java ) query.setParameter("tag", tag) - database.fullInitialize(query.list()) + query.list() } } } \ No newline at end of file diff --git a/src/main/kotlin/fr/ziedelth/repositories/EpisodeRepository.kt b/src/main/kotlin/fr/ziedelth/repositories/EpisodeRepository.kt index c8591f6..1455081 100644 --- a/src/main/kotlin/fr/ziedelth/repositories/EpisodeRepository.kt +++ b/src/main/kotlin/fr/ziedelth/repositories/EpisodeRepository.kt @@ -24,7 +24,7 @@ class EpisodeRepository : AbstractRepository() { limit, "FROM Episode WHERE anime.uuid = :uuid ${ when (sortType) { - SortType.SEASON_NUMBER -> "ORDER BY season DESC, number DESC, episodeType.name, langType.name" + SortType.SEASON_NUMBER -> "ORDER BY season DESC, episodeType.name, number DESC, langType.name" else -> ORDER } }", @@ -59,7 +59,7 @@ class EpisodeRepository : AbstractRepository() { } fun getLastNumber(episode: Episode): Int { - return database.inTransaction { + return database.inReadOnlyTransaction { val query = it.createQuery( "SELECT number FROM Episode WHERE anime.uuid = :uuid AND platform = :platform AND season = :season AND episodeType.uuid = :episodeType AND langType.uuid = :langType ORDER BY number DESC", Int::class.java @@ -75,7 +75,7 @@ class EpisodeRepository : AbstractRepository() { } fun getTotalDurationSeen(episodes: List): Long { - return database.inTransaction { + return database.inReadOnlyTransaction { it.createQuery("SELECT SUM(duration) FROM Episode WHERE uuid IN :uuids AND duration > 0", Long::class.java) .setParameter("uuids", episodes) .uniqueResult() ?: 0L diff --git a/src/main/kotlin/fr/ziedelth/repositories/ProfileRepository.kt b/src/main/kotlin/fr/ziedelth/repositories/ProfileRepository.kt new file mode 100644 index 0000000..3941859 --- /dev/null +++ b/src/main/kotlin/fr/ziedelth/repositories/ProfileRepository.kt @@ -0,0 +1,256 @@ +package fr.ziedelth.repositories + +import com.google.inject.Inject +import fr.ziedelth.controllers.AbstractController +import fr.ziedelth.dtos.MissingAnimeDto +import fr.ziedelth.entities.* +import fr.ziedelth.utils.toISO8601 +import java.util.* + +class ProfileRepository : AbstractRepository() { + @Inject + private lateinit var animeRepository: AnimeRepository + + @Inject + private lateinit var episodeRepository: EpisodeRepository + + /** + * Finds a profile by token value. + * + * @param value The token value to search for + * @return The profile that matches the token value, or null if no match is found + */ + fun findByToken(value: UUID?): Profile? { + return database.inReadOnlyTransaction { + val query = it.createQuery("FROM Profile WHERE tokenUuid = :value", Profile::class.java) + query.setParameter("value", value) + query.uniqueResult() + } + } + + /** + * Saves the provided filter data and returns the saved profile. + * + * @param filterData the filter data to be saved + * @return the saved profile + */ + fun save(filterData: AbstractController.FilterData): Profile { + val animes = animeRepository.findAll(filterData.animes).toMutableSet() + val episodes = episodeRepository.findAll(filterData.episodes).toMutableSet() + + val newProfile = save(Profile()) + newProfile.animes.addAll(animes.map { ProfileAnime(profile = newProfile, anime = it) }) + newProfile.episodes.addAll(episodes.map { ProfileEpisode(profile = newProfile, episode = it) }) + return save(newProfile) + } + + private fun addAnimeToProfile(anime: UUID, loggedInProfile: Profile): Boolean { + if (loggedInProfile.animes.any { it.anime!!.uuid == anime }) { + return true + } + + val animeToAdd = animeRepository.find(anime) ?: return true + loggedInProfile.animes.add(ProfileAnime(profile = loggedInProfile, anime = animeToAdd)) + return false + } + + private fun addEpisodeToProfile(episode: UUID, loggedInProfile: Profile): Boolean { + if (loggedInProfile.episodes.any { it.episode!!.uuid == episode }) { + return true + } + + val episodeToAdd = episodeRepository.find(episode) ?: return true + loggedInProfile.episodes.add(ProfileEpisode(profile = loggedInProfile, episode = episodeToAdd)) + return false + } + + /** + * Adds an anime or episode to the watchlist of a given profile. + * + * @param profile the profile to add the anime or episode to + * @param anime the name of the anime to add (optional) + * @param episode the episode number to add (optional) + * + * @return the updated profile after adding the anime or episode to the watchlist, or null if the anime or episode could not be added + */ + fun addToWatchlist(profile: Profile, anime: String?, episode: String?): Profile? { + if (anime != null && addAnimeToProfile(UUID.fromString(anime), profile)) return null + if (episode != null && addEpisodeToProfile(UUID.fromString(episode), profile)) return null + profile.lastUpdate = Calendar.getInstance().toISO8601() + return save(profile) + } + + private fun removeAnimeToProfile(anime: UUID, loggedInProfile: Profile): Boolean { + if (loggedInProfile.animes.none { it.anime!!.uuid == anime }) { + return true + } + + return !loggedInProfile.animes.removeIf { it.anime!!.uuid == anime } + } + + private fun removeEpisodeToProfile(episode: UUID, loggedInProfile: Profile): Boolean { + if (loggedInProfile.episodes.none { it.episode!!.uuid == episode }) { + return true + } + + return !loggedInProfile.episodes.removeIf { it.episode!!.uuid == episode } + } + + /** + * Removes anime or episode from the watchlist for a given profile. + * + * @param profile the profile from which to remove the anime or episode + * @param anime the anime to remove (optional) + * @param episode the episode to remove (optional) + * @return the updated Profile object if the anime or episode was successfully removed, null otherwise + */ + fun removeToWatchlist(profile: Profile, anime: String?, episode: String?): Profile? { + if (anime != null && removeAnimeToProfile(UUID.fromString(anime), profile)) return null + if (episode != null && removeEpisodeToProfile(UUID.fromString(episode), profile)) return null + profile.lastUpdate = Calendar.getInstance().toISO8601() + return save(profile) + } + + /** + * This method retrieves a list of missing anime episodes for a given profile. + * + * @param uuid The UUID of the profile to retrieve missing anime episodes for. + * @param episodeTypes The list of UUIDs representing the episode types to include in the search. + * @param langTypes The list of UUIDs representing the language types to include in the search. + * @param page The page number of the result set to retrieve. + * @param limit The maximum number of results per page. + * @return A list of MissingAnimeDto objects representing the missing anime episodes. + */ + fun getMissingAnimes( + uuid: UUID, + episodeTypes: List, + langTypes: List, + page: Int, + limit: Int + ): List { + val episodes = database.inReadOnlyTransaction { + it.createQuery( + "SELECT e.episode.uuid FROM Profile p JOIN p.episodes e WHERE p.uuid = :uuid", + UUID::class.java + ).setParameter("uuid", uuid).resultList + } + + val exclusionCondition = if (episodes.isEmpty()) "" else "e.uuid NOT IN :episodes AND" + val countEpisodes = + "(SELECT COUNT(e) FROM Episode e WHERE e.anime = an.anime AND $exclusionCondition e.episodeType.uuid IN :episodeTypes AND e.langType.uuid IN :langTypes)" + val latestReleaseDate = + "(SELECT MAX(e.releaseDate) FROM Episode e WHERE e.anime = an.anime AND $exclusionCondition e.episodeType.uuid IN :episodeTypes AND e.langType.uuid IN :langTypes)" + + return super.getByPage( + MissingAnimeDto::class.java, + page, + limit, + """ + SELECT new fr.ziedelth.dtos.MissingAnimeDto( + an.anime, + $countEpisodes, + $latestReleaseDate + ) + FROM Profile p + JOIN p.animes an + WHERE p.uuid = :uuid AND $countEpisodes > 0 + ORDER BY $latestReleaseDate DESC + """.trimIndent(), + "uuid" to uuid, + if (episodes.isEmpty()) null else "episodes" to episodes, + "episodeTypes" to episodeTypes, + "langTypes" to langTypes + ) + } + + fun getMissingEpisodes( + uuid: UUID, + episodeTypes: List, + langTypes: List, + page: Int, + limit: Int + ): List { + val (animes, episodes) = database.inReadOnlyTransaction { + val animes = it.createQuery( + "SELECT a.anime.uuid FROM Profile p JOIN p.animes a WHERE p.uuid = :uuid", + UUID::class.java + ).setParameter("uuid", uuid).resultList + + val episodes = it.createQuery( + "SELECT e.episode.uuid FROM Profile p JOIN p.episodes e WHERE p.uuid = :uuid", + UUID::class.java + ).setParameter("uuid", uuid).resultList + + animes to episodes + } + + val exclusionCondition = if (episodes.isEmpty()) "" else "e.uuid NOT IN :episodes AND" + + return super.getByPage( + Episode::class.java, + page, + limit, + """ + FROM Episode e + WHERE e.anime.uuid IN :animes + AND $exclusionCondition + e.episodeType.uuid IN :episodeTypes + AND e.langType.uuid IN :langTypes + ORDER BY releaseDate DESC, anime.name, season DESC, number DESC, episodeType.name, langType.name + """.trimIndent(), + "animes" to animes, + if (episodes.isEmpty()) null else "episodes" to episodes, + "episodeTypes" to episodeTypes, + "langTypes" to langTypes + ) + } + + /** + * Retrieves a list of Anime objects from the watchlist of a profile identified by the given UUID. + * + * @param uuid The UUID of the profile to retrieve watchlist animes from. + * @param page The page number of the results to retrieve. The first page is 1. + * @param limit The maximum number of results to retrieve per page. + * + * @return A list of Anime objects from the watchlist, ordered by the date they were added in descending order. + */ + fun getWatchlistAnimes(uuid: UUID, page: Int, limit: Int): List { + return super.getByPage( + Anime::class.java, + page, + limit, + """ + SELECT a.anime + FROM Profile p + JOIN p.animes a + WHERE p.uuid = :uuid + ORDER BY a.addDate DESC + """.trimIndent(), + "uuid" to uuid + ) + } + + /** + * Retrieves a list of episodes from the watchlist for a specific user. + * + * @param uuid The UUID of the user. + * @param page The page number to retrieve. + * @param limit The maximum number of episodes to retrieve per page. + * @return A list of Anime objects representing the episodes in the watchlist. + */ + fun getWatchlistEpisodes(uuid: UUID, page: Int, limit: Int): List { + return super.getByPage( + Anime::class.java, + page, + limit, + """ + SELECT e.episode + FROM Profile p + JOIN p.episodes e + WHERE p.uuid = :uuid + ORDER BY e.addDate DESC + """.trimIndent(), + "uuid" to uuid + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/ziedelth/utils/Constant.kt b/src/main/kotlin/fr/ziedelth/utils/Constant.kt index c7a21ff..051cf2d 100644 --- a/src/main/kotlin/fr/ziedelth/utils/Constant.kt +++ b/src/main/kotlin/fr/ziedelth/utils/Constant.kt @@ -9,4 +9,9 @@ object Constant { val seasons = listOf("WINTER", "SPRING", "SUMMER", "AUTUMN") val secureKey: String? = System.getenv("SECURE_KEY") + val jwtAudience: String = System.getenv("JWT_AUDIENCE") ?: "jwtAudience" + val jwtDomain: String = System.getenv("JWT_DOMAIN") ?: "jwtDomain" + val jwtRealm: String = System.getenv("JWT_REALM") ?: "jwtRealm" + val jwtSecret: String = System.getenv("JWT_SECRET") ?: "jwtSecret" + const val JWT_TOKEN_TIMEOUT = 1 * 60 * 60 * 1000 } \ No newline at end of file diff --git a/src/main/kotlin/fr/ziedelth/utils/Database.kt b/src/main/kotlin/fr/ziedelth/utils/Database.kt index 66abb98..3098ae4 100644 --- a/src/main/kotlin/fr/ziedelth/utils/Database.kt +++ b/src/main/kotlin/fr/ziedelth/utils/Database.kt @@ -1,7 +1,5 @@ package fr.ziedelth.utils -import jakarta.persistence.Entity -import org.hibernate.Hibernate import org.hibernate.Session import org.hibernate.SessionFactory import org.hibernate.boot.registry.StandardServiceRegistryBuilder @@ -59,34 +57,16 @@ open class Database { protected fun getEntities(): MutableSet> = Reflections("fr.ziedelth.entities").getSubTypesOf(Serializable::class.java) - private fun getNewSession(): Session = sessionFactory.currentSession + private fun getCurrentSession(): Session = sessionFactory.openSession() - fun fullInitialize(entity: T): T { - if (entity is Collection<*>) { - entity.forEach { fullInitialize(it!!) } - return entity - } - - entity?.let { ent -> - ent::class.java.declaredFields.forEach { - if (it.type == Set::class.java) { - it.isAccessible = true - val value = it[ent] ?: return@forEach - Hibernate.initialize(value) - fullInitialize(value) - } else if (it.type.isAnnotationPresent(Entity::class.java)) { - it.isAccessible = true - val value = it[ent] ?: return@forEach - fullInitialize(value) - } - } - } - - return entity + private fun openReadOnlySession(): Session { + val session = sessionFactory.openSession() + session.isDefaultReadOnly = true + return session } fun inTransaction(block: (Session) -> T): T { - getNewSession().use { session -> + getCurrentSession().use { session -> val transaction = session.beginTransaction() try { @@ -96,6 +76,20 @@ open class Database { } catch (e: Exception) { transaction.rollback() throw e + } finally { + session.close() + } + } + } + + fun inReadOnlyTransaction(block: (Session) -> T): T { + openReadOnlySession().use { session -> + try { + return block(session) + } catch (e: Exception) { + throw e + } finally { + session.close() } } } diff --git a/src/main/kotlin/fr/ziedelth/utils/routes/Authenticated.kt b/src/main/kotlin/fr/ziedelth/utils/routes/Authenticated.kt new file mode 100644 index 0000000..55017f8 --- /dev/null +++ b/src/main/kotlin/fr/ziedelth/utils/routes/Authenticated.kt @@ -0,0 +1,5 @@ +package fr.ziedelth.utils.routes + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class Authenticated diff --git a/src/main/kotlin/fr/ziedelth/utils/routes/BodyParam.kt b/src/main/kotlin/fr/ziedelth/utils/routes/BodyParam.kt new file mode 100644 index 0000000..e0c1f62 --- /dev/null +++ b/src/main/kotlin/fr/ziedelth/utils/routes/BodyParam.kt @@ -0,0 +1,5 @@ +package fr.ziedelth.utils.routes + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class BodyParam diff --git a/src/main/kotlin/fr/ziedelth/utils/routes/JWTUser.kt b/src/main/kotlin/fr/ziedelth/utils/routes/JWTUser.kt new file mode 100644 index 0000000..e0529b8 --- /dev/null +++ b/src/main/kotlin/fr/ziedelth/utils/routes/JWTUser.kt @@ -0,0 +1,5 @@ +package fr.ziedelth.utils.routes + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class JWTUser diff --git a/src/main/kotlin/fr/ziedelth/utils/routes/QueryParam.kt b/src/main/kotlin/fr/ziedelth/utils/routes/QueryParam.kt new file mode 100644 index 0000000..619703c --- /dev/null +++ b/src/main/kotlin/fr/ziedelth/utils/routes/QueryParam.kt @@ -0,0 +1,5 @@ +package fr.ziedelth.utils.routes + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class QueryParam diff --git a/src/main/kotlin/fr/ziedelth/utils/routes/Response.kt b/src/main/kotlin/fr/ziedelth/utils/routes/Response.kt index 27c32df..6a7d50f 100644 --- a/src/main/kotlin/fr/ziedelth/utils/routes/Response.kt +++ b/src/main/kotlin/fr/ziedelth/utils/routes/Response.kt @@ -7,8 +7,9 @@ open class Response( val data: Any? = null, ) { companion object { - fun ok(data: Any?): Response = Response(HttpStatusCode.OK, data) + fun ok(data: Any? = null): Response = Response(HttpStatusCode.OK, data) fun created(data: Any?): Response = Response(HttpStatusCode.Created, data) + fun noContent(): Response = Response(HttpStatusCode.NoContent) } } diff --git a/src/test/kotlin/fr/ziedelth/controllers/EpisodeControllerTest.kt b/src/test/kotlin/fr/ziedelth/controllers/EpisodeControllerTest.kt index f915603..a49b78c 100644 --- a/src/test/kotlin/fr/ziedelth/controllers/EpisodeControllerTest.kt +++ b/src/test/kotlin/fr/ziedelth/controllers/EpisodeControllerTest.kt @@ -38,6 +38,11 @@ internal class EpisodeControllerTest : AbstractAPITest() { expect(HttpStatusCode.OK) { responseNotCached.status } expect(12) { jsonNotCached.size } + requireNotNull(jsonNotCached[0].anime) + requireNotNull(jsonNotCached[0].platform) + requireNotNull(jsonNotCached[0].episodeType) + requireNotNull(jsonNotCached[0].langType) + // CACHED val responseCached = @@ -46,6 +51,11 @@ internal class EpisodeControllerTest : AbstractAPITest() { expect(HttpStatusCode.OK) { responseCached.status } expect(12) { jsonCached.size } + + requireNotNull(jsonCached[0].anime) + requireNotNull(jsonCached[0].platform) + requireNotNull(jsonCached[0].episodeType) + requireNotNull(jsonCached[0].langType) } } @@ -232,7 +242,7 @@ internal class EpisodeControllerTest : AbstractAPITest() { val platform = platformRepository.getAll().first() val anime = animeRepository.getAll() val episodeType = episodeTypeRepository.getAll().last() - val langType = langTypeRepository.getAll().first() + val langType = langTypeRepository.getAll().find { it.name == "SUBTITLES" } val date = "2023-09-28T00:00:00Z" @@ -320,7 +330,7 @@ internal class EpisodeControllerTest : AbstractAPITest() { val platform = platformRepository.getAll().first() val animes = animeRepository.getAll() val episodeType = episodeTypeRepository.getAll().last() - val langType = langTypeRepository.getAll().first() + val langType = langTypeRepository.getAll().find { it.name == "SUBTITLES" } val date = "2023-10-10T00:00:00Z" diff --git a/src/test/kotlin/fr/ziedelth/controllers/ProfileControllerTest.kt b/src/test/kotlin/fr/ziedelth/controllers/ProfileControllerTest.kt index e5626b8..fcc9361 100644 --- a/src/test/kotlin/fr/ziedelth/controllers/ProfileControllerTest.kt +++ b/src/test/kotlin/fr/ziedelth/controllers/ProfileControllerTest.kt @@ -2,14 +2,17 @@ package fr.ziedelth.controllers import com.google.gson.JsonObject import fr.ziedelth.AbstractAPITest -import fr.ziedelth.plugins.configureHTTP -import fr.ziedelth.plugins.configureRoutingTest +import fr.ziedelth.dtos.ProfileDto +import fr.ziedelth.entities.Anime +import fr.ziedelth.entities.Episode +import fr.ziedelth.plugins.* import fr.ziedelth.utils.Constant import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* import org.junit.jupiter.api.Test +import java.util.* import kotlin.test.assertNotSame import kotlin.test.expect @@ -33,4 +36,440 @@ internal class ProfileControllerTest : AbstractAPITest() { expect(1440) { totalDuration } } } + + @Test + fun registerWithoutData() { + testApplication { + application { + configureHTTP() + configureRoutingTest() + } + + val response = client.post("/profile/register") { setBody("") } + expect(HttpStatusCode.OK) { response.status } + + val json = Constant.gson.fromJson(response.bodyAsText(), JsonObject::class.java) + val tokenUuid = json.getAsJsonPrimitive("tokenUuid").asString + + assertNotSame(null, tokenUuid) + assertNotSame("", tokenUuid) + + val profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + assertNotSame(null, profile) + expect(profile?.tokenUuid.toString()) { tokenUuid } + } + } + + @Test + fun registerWithData() { + testApplication { + application { + configureHTTP() + configureRoutingTest() + } + + val response = client.post("/profile/register") { setBody(getFilterDataEncoded()) } + expect(HttpStatusCode.OK) { response.status } + + val json = Constant.gson.fromJson(response.bodyAsText(), JsonObject::class.java) + val tokenUuid = json.getAsJsonPrimitive("tokenUuid").asString + + assertNotSame(null, tokenUuid) + assertNotSame("", tokenUuid) + + val profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + assertNotSame(null, profile) + expect(profile?.tokenUuid.toString()) { tokenUuid } + expect(profile?.animes?.size) { 1 } + expect(profile?.episodes?.size) { 1 } + } + } + + @Test + fun login() { + testApplication { + application { + configureHTTP() + configureRoutingTest() + } + + val response = client.post("/profile/register") { setBody(getFilterDataEncoded()) } + expect(HttpStatusCode.OK) { response.status } + + val json = Constant.gson.fromJson(response.bodyAsText(), JsonObject::class.java) + val tokenUuid = json.getAsJsonPrimitive("tokenUuid").asString + + assertNotSame(null, tokenUuid) + assertNotSame("", tokenUuid) + + val profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + assertNotSame(null, profile) + expect(profile?.tokenUuid.toString()) { tokenUuid } + expect(profile?.animes?.size) { 1 } + expect(profile?.episodes?.size) { 1 } + + val loginResponse = client.post("/profile/login") { setBody(tokenUuid) } + expect(HttpStatusCode.OK) { loginResponse.status } + + val profileDto = Constant.gson.fromJson(loginResponse.bodyAsText(), ProfileDto::class.java) + + assertNotSame(null, profileDto.token) + assertNotSame("", profileDto.token) + + expect(1440) { profileDto.totalDurationSeen } + expect(profile?.animes?.size) { profileDto.animes.size } + expect(profile?.episodes?.size) { profileDto.episodes.size } + } + } + + @Test + fun addAnimeToWatchlist() { + testApplication { + application { + configureHTTP() + configureRoutingTest() + } + + val response = client.post("/profile/register") + expect(HttpStatusCode.OK) { response.status } + + val json = Constant.gson.fromJson(response.bodyAsText(), JsonObject::class.java) + val tokenUuid = json.getAsJsonPrimitive("tokenUuid").asString + + assertNotSame(null, tokenUuid) + assertNotSame("", tokenUuid) + + var profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + assertNotSame(null, profile) + expect(profile?.tokenUuid.toString()) { tokenUuid } + expect(profile?.animes?.size) { 0 } + expect(profile?.episodes?.size) { 0 } + + // --- + + val loginResponse = client.post("/profile/login") { setBody(tokenUuid) } + expect(HttpStatusCode.OK) { loginResponse.status } + + val profileDto = Constant.gson.fromJson(loginResponse.bodyAsText(), ProfileDto::class.java) + + assertNotSame(null, profileDto.token) + assertNotSame("", profileDto.token) + + expect(0) { profileDto.totalDurationSeen } + expect(profile?.animes?.size) { profileDto.animes.size } + expect(profile?.episodes?.size) { profileDto.episodes.size } + + // --- + + val animeToAdd = animeRepository.getAll().first() + val addToWatchlistResponse = client.put("/profile/watchlist?anime=${animeToAdd.uuid}") { + header("Authorization", "Bearer ${profileDto.token}") + } + expect(HttpStatusCode.OK) { addToWatchlistResponse.status } + + profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + expect(profile?.animes?.size) { 1 } + expect(profile?.animes?.first()?.anime?.uuid) { animeToAdd.uuid } + + expect(profile?.episodes?.size) { 0 } + } + } + + @Test + fun addEpisodeToWatchlist() { + testApplication { + application { + configureHTTP() + configureRoutingTest() + } + + val response = client.post("/profile/register") + expect(HttpStatusCode.OK) { response.status } + + val json = Constant.gson.fromJson(response.bodyAsText(), JsonObject::class.java) + val tokenUuid = json.getAsJsonPrimitive("tokenUuid").asString + + assertNotSame(null, tokenUuid) + assertNotSame("", tokenUuid) + + var profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + assertNotSame(null, profile) + expect(profile?.tokenUuid.toString()) { tokenUuid } + expect(profile?.animes?.size) { 0 } + expect(profile?.episodes?.size) { 0 } + + // --- + + val loginResponse = client.post("/profile/login") { setBody(tokenUuid) } + expect(HttpStatusCode.OK) { loginResponse.status } + + val profileDto = Constant.gson.fromJson(loginResponse.bodyAsText(), ProfileDto::class.java) + + assertNotSame(null, profileDto.token) + assertNotSame("", profileDto.token) + + expect(0) { profileDto.totalDurationSeen } + expect(profile?.animes?.size) { profileDto.animes.size } + expect(profile?.episodes?.size) { profileDto.episodes.size } + + // --- + + val episodeToAdd = episodeRepository.getAll().first() + val addToWatchlistResponse = client.put("/profile/watchlist?episode=${episodeToAdd.uuid}") { + header("Authorization", "Bearer ${profileDto.token}") + } + expect(HttpStatusCode.OK) { addToWatchlistResponse.status } + + profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + expect(profile?.animes?.size) { 0 } + + expect(profile?.episodes?.size) { 1 } + expect(profile?.episodes?.first()?.episode?.uuid) { episodeToAdd.uuid } + } + } + + @Test + fun removeAnimeFromWatchlist() { + testApplication { + application { + configureHTTP() + configureRoutingTest() + } + + val response = client.post("/profile/register") + expect(HttpStatusCode.OK) { response.status } + + val json = Constant.gson.fromJson(response.bodyAsText(), JsonObject::class.java) + val tokenUuid = json.getAsJsonPrimitive("tokenUuid").asString + + assertNotSame(null, tokenUuid) + assertNotSame("", tokenUuid) + + var profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + assertNotSame(null, profile) + expect(profile?.tokenUuid.toString()) { tokenUuid } + expect(profile?.animes?.size) { 0 } + expect(profile?.episodes?.size) { 0 } + + // --- + + val loginResponse = client.post("/profile/login") { setBody(tokenUuid) } + expect(HttpStatusCode.OK) { loginResponse.status } + + val profileDto = Constant.gson.fromJson(loginResponse.bodyAsText(), ProfileDto::class.java) + + assertNotSame(null, profileDto.token) + assertNotSame("", profileDto.token) + + expect(0) { profileDto.totalDurationSeen } + expect(profile?.animes?.size) { profileDto.animes.size } + expect(profile?.episodes?.size) { profileDto.episodes.size } + + // --- + + val animeToRemove = animeRepository.getAll().first() + val addToWatchlistResponse = client.put("/profile/watchlist?anime=${animeToRemove.uuid}") { + header("Authorization", "Bearer ${profileDto.token}") + } + expect(HttpStatusCode.OK) { addToWatchlistResponse.status } + + profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + expect(profile?.animes?.size) { 1 } + expect(profile?.animes?.first()?.anime?.uuid) { animeToRemove.uuid } + + expect(profile?.episodes?.size) { 0 } + + // --- + + val removeFromWatchlistResponse = client.delete("/profile/watchlist?anime=${animeToRemove.uuid}") { + header("Authorization", "Bearer ${profileDto.token}") + } + expect(HttpStatusCode.OK) { removeFromWatchlistResponse.status } + + profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + expect(profile?.animes?.size) { 0 } + expect(profile?.episodes?.size) { 0 } + } + } + + @Test + fun removeEpisodeFromWatchlist() { + testApplication { + application { + configureHTTP() + configureRoutingTest() + } + + val response = client.post("/profile/register") + expect(HttpStatusCode.OK) { response.status } + + val json = Constant.gson.fromJson(response.bodyAsText(), JsonObject::class.java) + val tokenUuid = json.getAsJsonPrimitive("tokenUuid").asString + + assertNotSame(null, tokenUuid) + assertNotSame("", tokenUuid) + + var profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + assertNotSame(null, profile) + expect(profile?.tokenUuid.toString()) { tokenUuid } + expect(profile?.animes?.size) { 0 } + expect(profile?.episodes?.size) { 0 } + + // --- + + val loginResponse = client.post("/profile/login") { setBody(tokenUuid) } + expect(HttpStatusCode.OK) { loginResponse.status } + + val profileDto = Constant.gson.fromJson(loginResponse.bodyAsText(), ProfileDto::class.java) + + assertNotSame(null, profileDto.token) + assertNotSame("", profileDto.token) + + expect(0) { profileDto.totalDurationSeen } + expect(profile?.animes?.size) { profileDto.animes.size } + expect(profile?.episodes?.size) { profileDto.episodes.size } + + // --- + + val episodeToRemove = episodeRepository.getAll().first() + val addToWatchlistResponse = client.put("/profile/watchlist?episode=${episodeToRemove.uuid}") { + header("Authorization", "Bearer ${profileDto.token}") + } + expect(HttpStatusCode.OK) { addToWatchlistResponse.status } + + profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + expect(profile?.animes?.size) { 0 } + + expect(profile?.episodes?.size) { 1 } + expect(profile?.episodes?.first()?.episode?.uuid) { episodeToRemove.uuid } + + // --- + + val removeFromWatchlistResponse = client.delete("/profile/watchlist?episode=${episodeToRemove.uuid}") { + header("Authorization", "Bearer ${profileDto.token}") + } + expect(HttpStatusCode.OK) { removeFromWatchlistResponse.status } + + profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + expect(profile?.animes?.size) { 0 } + expect(profile?.episodes?.size) { 0 } + } + } + + @Test + fun getWatchlistAnimes() { + testApplication { + application { + configureHTTP() + configureRoutingTest() + } + + val response = client.post("/profile/register") { setBody(getFilterDataEncoded()) } + expect(HttpStatusCode.OK) { response.status } + + val json = Constant.gson.fromJson(response.bodyAsText(), JsonObject::class.java) + val tokenUuid = json.getAsJsonPrimitive("tokenUuid").asString + + assertNotSame(null, tokenUuid) + assertNotSame("", tokenUuid) + + val profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + assertNotSame(null, profile) + expect(profile?.tokenUuid.toString()) { tokenUuid } + expect(profile?.animes?.size) { 1 } + expect(profile?.episodes?.size) { 1 } + + // --- + + val loginResponse = client.post("/profile/login") { setBody(tokenUuid) } + expect(HttpStatusCode.OK) { loginResponse.status } + + val profileDto = Constant.gson.fromJson(loginResponse.bodyAsText(), ProfileDto::class.java) + + assertNotSame(null, profileDto.token) + assertNotSame("", profileDto.token) + + expect(1440) { profileDto.totalDurationSeen } + expect(profile?.animes?.size) { profileDto.animes.size } + expect(profile?.episodes?.size) { profileDto.episodes.size } + + // --- + + val getWatchlistAnimesResponse = client.get("/profile/watchlist/animes/page/1/limit/12") { + header("Authorization", "Bearer ${profileDto.token}") + } + expect(HttpStatusCode.OK) { getWatchlistAnimesResponse.status } + + val watchlistAnimes = Constant.gson.fromJson(getWatchlistAnimesResponse.bodyAsText(), Array::class.java) + + expect(1) { watchlistAnimes.size } + expect(profile?.animes?.first()?.anime?.uuid) { watchlistAnimes.first().uuid } + } + } + + @Test + fun getWatchlistEpisodes() { + testApplication { + application { + configureHTTP() + configureRoutingTest() + } + + val response = client.post("/profile/register") { setBody(getFilterDataEncoded()) } + expect(HttpStatusCode.OK) { response.status } + + val json = Constant.gson.fromJson(response.bodyAsText(), JsonObject::class.java) + val tokenUuid = json.getAsJsonPrimitive("tokenUuid").asString + + assertNotSame(null, tokenUuid) + assertNotSame("", tokenUuid) + + val profile = profileRepository.findByToken(UUID.fromString(tokenUuid)) + + assertNotSame(null, profile) + expect(profile?.tokenUuid.toString()) { tokenUuid } + expect(profile?.animes?.size) { 1 } + expect(profile?.episodes?.size) { 1 } + + // --- + + val loginResponse = client.post("/profile/login") { setBody(tokenUuid) } + expect(HttpStatusCode.OK) { loginResponse.status } + + val profileDto = Constant.gson.fromJson(loginResponse.bodyAsText(), ProfileDto::class.java) + + assertNotSame(null, profileDto.token) + assertNotSame("", profileDto.token) + + expect(1440) { profileDto.totalDurationSeen } + expect(profile?.animes?.size) { profileDto.animes.size } + expect(profile?.episodes?.size) { profileDto.episodes.size } + + // --- + + val getWatchlistEpisodesResponse = client.get("/profile/watchlist/episodes/page/1/limit/12") { + header("Authorization", "Bearer ${profileDto.token}") + } + expect(HttpStatusCode.OK) { getWatchlistEpisodesResponse.status } + + val watchlistEpisodes = Constant.gson.fromJson(getWatchlistEpisodesResponse.bodyAsText(), Array::class.java) + + expect(1) { watchlistEpisodes.size } + expect(profile?.episodes?.first()?.episode?.uuid) { watchlistEpisodes.first().uuid } + } + } } \ No newline at end of file diff --git a/src/test/kotlin/fr/ziedelth/plugins/RoutingTest.kt b/src/test/kotlin/fr/ziedelth/plugins/RoutingTest.kt index b740594..65706e5 100644 --- a/src/test/kotlin/fr/ziedelth/plugins/RoutingTest.kt +++ b/src/test/kotlin/fr/ziedelth/plugins/RoutingTest.kt @@ -21,6 +21,7 @@ val animeRepository: AnimeRepository = injector.getInstance(AnimeRepository::cla val episodeTypeRepository: EpisodeTypeRepository = injector.getInstance(EpisodeTypeRepository::class.java) val langTypeRepository: LangTypeRepository = injector.getInstance(LangTypeRepository::class.java) val episodeRepository: EpisodeRepository = injector.getInstance(EpisodeRepository::class.java) +val profileRepository: ProfileRepository = injector.getInstance(ProfileRepository::class.java) fun Application.configureRoutingTest() { routing { diff --git a/src/test/resources/hibernate.cfg.xml b/src/test/resources/hibernate.cfg.xml index 1136bf9..6408681 100644 --- a/src/test/resources/hibernate.cfg.xml +++ b/src/test/resources/hibernate.cfg.xml @@ -12,8 +12,8 @@ org.hibernate.dialect.H2Dialect false create-drop - - org.hibernate.context.internal.ThreadLocalSessionContext - + org.hibernate.context.internal.ThreadLocalSessionContext + true + jcache \ No newline at end of file