diff --git a/build.gradle.kts b/build.gradle.kts index 34c18e7a..9cf25fb3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation("io.ktor:ktor-server-netty-jvm:$ktor_version") implementation("io.ktor:ktor-client-core:$ktor_version") implementation("io.ktor:ktor-client-okhttp:$ktor_version") + implementation("io.github.smiley4:ktor-swagger-ui:2.7.2") implementation("org.hibernate.orm:hibernate-core:6.4.1.Final") implementation("org.hibernate.search:hibernate-search-mapper-orm:7.1.0.Alpha1") implementation("org.hibernate.search:hibernate-search-backend-lucene:7.1.0.Alpha1") @@ -56,6 +57,7 @@ dependencies { implementation("com.mortennobel:java-image-scaling:0.8.6") implementation("io.ktor:ktor-client-okhttp-jvm:2.3.7") testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version") + testImplementation("io.ktor:ktor-client-mock:$ktor_version") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") testImplementation("com.h2database:h2:2.2.224") } diff --git a/src/main/kotlin/fr/shikkanime/Application.kt b/src/main/kotlin/fr/shikkanime/Application.kt index 5ae576e2..8fabd4a6 100644 --- a/src/main/kotlin/fr/shikkanime/Application.kt +++ b/src/main/kotlin/fr/shikkanime/Application.kt @@ -35,22 +35,33 @@ fun main() { Constant.injector.getInstance(MemberService::class.java).initDefaultAdminUser() - runBlocking { - val httpResponse = HttpRequest().get("https://beta-api.ziedelth.fr/episodes/country/fr/page/1/limit/30") + // Sync episodes from Jais + if (false) { + val episodes = mutableListOf() - if (httpResponse.status.isSuccess()) { - val episodes = - AbstractConverter.convert(ObjectParser.fromJson(httpResponse.bodyAsText(), Array::class.java).toList(), Episode::class.java) - episodes.filter { it.uuid == null }.forEach { episodeService.save(it) } + (141 downTo 1).forEach { + runBlocking { + val httpResponse = HttpRequest().get("https://beta-api.ziedelth.fr/episodes/country/fr/page/$it/limit/30") + + if (httpResponse.status.isSuccess()) { + episodes.addAll( + AbstractConverter.convert( + ObjectParser.fromJson(httpResponse.bodyAsText(), Array::class.java).toList(), + Episode::class.java + ) + ) + } + } } + + episodes.filter { it.uuid == null || it.platform?.name != "Disney+" }.forEach { episodeService.save(it) } } println("Starting jobs...") JobManager.scheduleJob("*/10 * * * * ?", MetricJob::class.java) - JobManager.scheduleJob("0 */5 * * * ?", GCJob::class.java) JobManager.scheduleJob("0 * * * * ?", FetchEpisodesJob::class.java) - JobManager.scheduleJob("0 */5 * * * ?", SavingImageCacheJob::class.java) - JobManager.scheduleJob("0 */15 * * * ?", BackupJob::class.java) + JobManager.scheduleJob("0 0 * * * ?", SavingImageCacheJob::class.java) + JobManager.scheduleJob("0 */10 * * * ?", GarbageCollectorJob::class.java) JobManager.start() println("Starting server...") diff --git a/src/main/kotlin/fr/shikkanime/controllers/AdminController.kt b/src/main/kotlin/fr/shikkanime/controllers/AdminController.kt index eb8e2e7a..84713130 100644 --- a/src/main/kotlin/fr/shikkanime/controllers/AdminController.kt +++ b/src/main/kotlin/fr/shikkanime/controllers/AdminController.kt @@ -6,9 +6,14 @@ import fr.shikkanime.dtos.MemberDto import fr.shikkanime.entities.enums.Link import fr.shikkanime.services.MemberService import fr.shikkanime.utils.Constant -import fr.shikkanime.utils.routes.* -import fr.ziedelth.utils.routes.method.Get -import fr.ziedelth.utils.routes.method.Post +import fr.shikkanime.utils.routes.AdminSessionAuthenticated +import fr.shikkanime.utils.routes.Controller +import fr.shikkanime.utils.routes.Path +import fr.shikkanime.utils.routes.Response +import fr.shikkanime.utils.routes.method.Get +import fr.shikkanime.utils.routes.method.Post +import fr.shikkanime.utils.routes.param.BodyParam +import fr.shikkanime.utils.routes.param.QueryParam import io.ktor.http.* @Controller("/admin") @@ -18,7 +23,7 @@ class AdminController { @Path @Get - private fun home(@QueryParam error: String?): Response { + private fun home(@QueryParam("error") error: String?): Response { return Response.template( "admin/login.ftl", "Login", diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/AnimeController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/AnimeController.kt index e22c5fbd..c6fef195 100644 --- a/src/main/kotlin/fr/shikkanime/controllers/api/AnimeController.kt +++ b/src/main/kotlin/fr/shikkanime/controllers/api/AnimeController.kt @@ -3,38 +3,142 @@ package fr.shikkanime.controllers.api import com.google.inject.Inject import fr.shikkanime.converters.AbstractConverter import fr.shikkanime.dtos.AnimeDto +import fr.shikkanime.dtos.MessageDto +import fr.shikkanime.entities.SortParameter import fr.shikkanime.entities.enums.CountryCode import fr.shikkanime.services.AnimeService import fr.shikkanime.services.ImageService -import fr.shikkanime.utils.routes.Cached -import fr.shikkanime.utils.routes.Controller -import fr.shikkanime.utils.routes.Path -import fr.shikkanime.utils.routes.Response -import fr.ziedelth.utils.routes.method.Get +import fr.shikkanime.utils.routes.* +import fr.shikkanime.utils.routes.method.Get +import fr.shikkanime.utils.routes.openapi.OpenAPI +import fr.shikkanime.utils.routes.openapi.OpenAPIResponse +import fr.shikkanime.utils.routes.param.PathParam +import fr.shikkanime.utils.routes.param.QueryParam import io.ktor.http.* import java.util.* -@Controller("/api/animes") +@Controller("/api/v1/animes") class AnimeController { @Inject private lateinit var animeService: AnimeService - @Path("/image/{uuid}") + @Path @Get - @Cached(maxAgeSeconds = 3600) - private fun getImage(uuid: UUID): Response { - val anime = animeService.find(uuid) ?: return Response.notFound("Anime not found") - val image = ImageService[anime.uuid!!] ?: return Response.notFound("Image not found") - return Response.multipart(image.bytes, ContentType.parse("image/webp")) + @OpenAPI( + "Get animes", + [ + OpenAPIResponse( + 200, + "Animes found", + Array::class, + ), + OpenAPIResponse( + 409, + "You can't use simulcast and name at the same time OR You can't use sort and desc with name", + MessageDto::class + ), + ] + ) + private fun getAll( + @QueryParam("name") name: String?, + @QueryParam("country") countryParam: CountryCode?, + @QueryParam("simulcast") simulcastParam: UUID?, + @QueryParam("page") pageParam: Int?, + @QueryParam("limit") limitParam: Int?, + @QueryParam("sort") sortParam: String?, + @QueryParam("desc") descParam: String?, + ): Response { + if (simulcastParam != null && name != null) { + return Response.conflict( + MessageDto( + MessageDto.Type.ERROR, + "You can't use simulcast and name at the same time", + ) + ) + } + + if (name != null && (sortParam != null || descParam != null)) { + return Response.conflict( + MessageDto( + MessageDto.Type.ERROR, + "You can't use sort and desc with name", + ) + ) + } + + val country = countryParam ?: CountryCode.FR + val page = pageParam ?: 1 + var limit = limitParam ?: 15 + if (limit < 1) limit = 1 + if (limit > 30) limit = 30 + + val sortParameters = mutableListOf() + + if (!sortParam.isNullOrBlank()) { + val sortParams = sortParam.split(",") + val descParams = descParam?.split(",") ?: listOf() + + sortParams.forEach { sort -> + val desc = descParams.contains(sort) + sortParameters.add(SortParameter(sort, if (desc) SortParameter.Order.DESC else SortParameter.Order.ASC)) + } + } + + val list = if (!name.isNullOrBlank()) { + animeService.findByName(name, country, page, limit) + } else if (simulcastParam != null) { + animeService.findBySimulcast(simulcastParam, country, sortParameters, page, limit) + } else { + animeService.findAll(sortParameters, page, limit) + } + + return Response.ok(AbstractConverter.convert(list, AnimeDto::class.java)) } - @Path("/search/{countryCode}/{name}") + @Path("/{uuid}") @Get - private fun searchByCountryCodeAndName(countryCode: CountryCode?, name: String): Response { - if (countryCode == null) { - return Response.badRequest("Country code is null") - } + @OpenAPI( + "Get anime", + [ + OpenAPIResponse( + 200, + "Anime found", + AnimeDto::class, + ), + OpenAPIResponse( + 404, + "Anime not found", + MessageDto::class, + ), + ] + ) + private fun getAnime(@PathParam("uuid") uuid: UUID): Response { + val anime = animeService.find(uuid) ?: return Response.notFound(MessageDto(MessageDto.Type.ERROR, "Anime not found")) + return Response.ok(AbstractConverter.convert(anime, AnimeDto::class.java)) + } - return Response.ok(AbstractConverter.convert(animeService.findByName(countryCode, name), AnimeDto::class.java)) + @Path("/{uuid}/image") + @Get + @Cached(maxAgeSeconds = 3600) + @OpenAPI( + "Get anime image", + [ + OpenAPIResponse( + 200, + "Image found", + ByteArray::class, + "image/webp" + ), + OpenAPIResponse( + 404, + "Anime not found OR Anime image not found", + MessageDto::class, + ), + ] + ) + private fun getAnimeImage(@PathParam("uuid") uuid: UUID): Response { + val anime = animeService.find(uuid) ?: return Response.notFound(MessageDto(MessageDto.Type.ERROR, "Anime not found")) + val image = ImageService[anime.uuid!!] ?: return Response.notFound(MessageDto(MessageDto.Type.ERROR, "Anime image not found")) + return Response.multipart(image.bytes, ContentType.parse("image/webp")) } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt index ea7663b7..808d421f 100644 --- a/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt +++ b/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt @@ -2,13 +2,16 @@ package fr.shikkanime.controllers.api import com.google.inject.Inject import fr.shikkanime.converters.AbstractConverter +import fr.shikkanime.dtos.MessageDto import fr.shikkanime.dtos.MetricDto import fr.shikkanime.services.MetricService import fr.shikkanime.utils.routes.AdminSessionAuthenticated import fr.shikkanime.utils.routes.Controller import fr.shikkanime.utils.routes.Path import fr.shikkanime.utils.routes.Response -import fr.ziedelth.utils.routes.method.Get +import fr.shikkanime.utils.routes.method.Get +import fr.shikkanime.utils.routes.openapi.OpenAPI +import fr.shikkanime.utils.routes.openapi.OpenAPIResponse import java.time.ZonedDateTime @Controller("/api/metrics") @@ -19,6 +22,21 @@ class MetricController { @Path @Get @AdminSessionAuthenticated + @OpenAPI( + "Get metrics", + [ + OpenAPIResponse( + 200, + "Metrics found", + Array::class, + ), + OpenAPIResponse( + 401, + "You are not authenticated", + MessageDto::class + ), + ] + ) private fun getMetrics(): Response { val oneHourAgo = ZonedDateTime.now().minusHours(1) return Response.ok(AbstractConverter.convert(metricService.findAllAfter(oneHourAgo), MetricDto::class.java)) diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/SimulcastController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/SimulcastController.kt new file mode 100644 index 00000000..b7cccf28 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/controllers/api/SimulcastController.kt @@ -0,0 +1,34 @@ +package fr.shikkanime.controllers.api + +import com.google.inject.Inject +import fr.shikkanime.converters.AbstractConverter +import fr.shikkanime.dtos.SimulcastDto +import fr.shikkanime.services.SimulcastService +import fr.shikkanime.utils.routes.* +import fr.shikkanime.utils.routes.method.Get +import fr.shikkanime.utils.routes.openapi.OpenAPI +import fr.shikkanime.utils.routes.openapi.OpenAPIResponse +import io.ktor.http.* +import java.util.* + +@Controller("/api/v1/simulcasts") +class SimulcastController { + @Inject + private lateinit var simulcastService: SimulcastService + + @Path + @Get + @OpenAPI( + "Get simulcasts", + [ + OpenAPIResponse( + 200, + "Simulcasts found", + Array::class, + ), + ] + ) + private fun getAll(): Response { + return Response.ok(AbstractConverter.convert(simulcastService.findAll(), SimulcastDto::class.java)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/converters/anime/AnimeToAnimeDtoConverter.kt b/src/main/kotlin/fr/shikkanime/converters/anime/AnimeToAnimeDtoConverter.kt index 47a022aa..4a671116 100644 --- a/src/main/kotlin/fr/shikkanime/converters/anime/AnimeToAnimeDtoConverter.kt +++ b/src/main/kotlin/fr/shikkanime/converters/anime/AnimeToAnimeDtoConverter.kt @@ -4,6 +4,7 @@ import fr.shikkanime.converters.AbstractConverter import fr.shikkanime.dtos.AnimeDto import fr.shikkanime.dtos.SimulcastDto import fr.shikkanime.entities.Anime +import org.hibernate.Hibernate import java.time.format.DateTimeFormatter class AnimeToAnimeDtoConverter : AbstractConverter() { @@ -11,10 +12,11 @@ class AnimeToAnimeDtoConverter : AbstractConverter() { return AnimeDto( uuid = from.uuid, releaseDateTime = from.releaseDateTime.format(DateTimeFormatter.ISO_DATE_TIME), + image = from.image, countryCode = from.countryCode!!, name = from.name!!, description = from.description, - simulcasts = convert(from.simulcasts, SimulcastDto::class.java) + simulcasts = if (Hibernate.isInitialized(from.simulcasts)) convert(from.simulcasts, SimulcastDto::class.java) else null, ) } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/converters/anime/JaisAnimeToAnimeConverter.kt b/src/main/kotlin/fr/shikkanime/converters/anime/JaisAnimeToAnimeConverter.kt index 9f48f110..33e4d18b 100644 --- a/src/main/kotlin/fr/shikkanime/converters/anime/JaisAnimeToAnimeConverter.kt +++ b/src/main/kotlin/fr/shikkanime/converters/anime/JaisAnimeToAnimeConverter.kt @@ -13,7 +13,7 @@ class JaisAnimeToAnimeConverter : AbstractConverter() { private lateinit var animeService: AnimeService override fun convert(from: AnimeDto): Anime { - val findByName = animeService.findByName(CountryCode.FR, from.name) + val findByName = animeService.findByLikeName(CountryCode.FR, from.name) if (findByName.isNotEmpty()) { return findByName.first() diff --git a/src/main/kotlin/fr/shikkanime/converters/member/MemberToUnsecuredMemberDtoConverter.kt b/src/main/kotlin/fr/shikkanime/converters/member/MemberToUnsecuredMemberDtoConverter.kt index ad52af5c..4f9ad3af 100644 --- a/src/main/kotlin/fr/shikkanime/converters/member/MemberToUnsecuredMemberDtoConverter.kt +++ b/src/main/kotlin/fr/shikkanime/converters/member/MemberToUnsecuredMemberDtoConverter.kt @@ -11,7 +11,7 @@ class MemberToUnsecuredMemberDtoConverter : AbstractConverter() { @Inject private lateinit var metricService: MetricService - private fun Double.toDoublePoint() = String.format("%.2f", this) - override fun convert(from: Metric): MetricDto { val minusHours = from.date.minusHours(1) @@ -26,7 +24,6 @@ class MetricToMetricDtoConverter : AbstractConverter() { averageCpuLoad = metricService.getAverageCpuLoad(minusHours, from.date).times(100).toString().replace(',', '.'), memoryUsage = (from.memoryUsage / 1024.0 / 1024.0).toString().replace(',', '.'), averageMemoryUsage = metricService.getAverageMemoryUsage(minusHours, from.date).div(1024).div(1024).toString().replace(',', '.'), - databaseSize = (from.databaseSize / 1024.0 / 1024.0).toDoublePoint(), date = from.date.withZoneSameInstant(utcZone).format(dateFormatter) ) } diff --git a/src/main/kotlin/fr/shikkanime/dtos/MessageDto.kt b/src/main/kotlin/fr/shikkanime/dtos/MessageDto.kt new file mode 100644 index 00000000..abdd3e23 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/dtos/MessageDto.kt @@ -0,0 +1,16 @@ +package fr.shikkanime.dtos + +import java.io.Serializable + +data class MessageDto( + val type: Type, + val message: String, + val data: Any? = null, +) : Serializable { + enum class Type { + INFO, + SUCCESS, + WARNING, + ERROR + } +} diff --git a/src/main/kotlin/fr/shikkanime/dtos/MetricDto.kt b/src/main/kotlin/fr/shikkanime/dtos/MetricDto.kt index eee54f99..c8f63236 100644 --- a/src/main/kotlin/fr/shikkanime/dtos/MetricDto.kt +++ b/src/main/kotlin/fr/shikkanime/dtos/MetricDto.kt @@ -9,6 +9,5 @@ data class MetricDto( val averageCpuLoad: String, val memoryUsage: String, val averageMemoryUsage: String, - val databaseSize: String, val date: String, ) : Serializable diff --git a/src/main/kotlin/fr/shikkanime/dtos/UnsecuredMemberDto.kt b/src/main/kotlin/fr/shikkanime/dtos/UnsecuredMemberDto.kt index e7181b28..ebec39a0 100644 --- a/src/main/kotlin/fr/shikkanime/dtos/UnsecuredMemberDto.kt +++ b/src/main/kotlin/fr/shikkanime/dtos/UnsecuredMemberDto.kt @@ -9,6 +9,6 @@ data class UnsecuredMemberDto( val uuid: UUID?, val creationDateTime: String, val username: String, - val password: ByteArray, + val encryptedPassword: ByteArray, val role: Role, ) : Principal, Serializable diff --git a/src/main/kotlin/fr/shikkanime/entities/Anime.kt b/src/main/kotlin/fr/shikkanime/entities/Anime.kt index 2d37d780..f47721ca 100644 --- a/src/main/kotlin/fr/shikkanime/entities/Anime.kt +++ b/src/main/kotlin/fr/shikkanime/entities/Anime.kt @@ -23,7 +23,7 @@ data class Anime( val releaseDateTime: ZonedDateTime = ZonedDateTime.now(), @Column(nullable = false, columnDefinition = "VARCHAR(1000)") var image: String? = null, - @Column(nullable = true, columnDefinition = "VARCHAR(1000)") + @Column(nullable = true, columnDefinition = "VARCHAR(2000)") var description: String? = null, @ManyToMany @JoinTable( diff --git a/src/main/kotlin/fr/shikkanime/entities/Member.kt b/src/main/kotlin/fr/shikkanime/entities/Member.kt index 6a87dcd2..f8f9907c 100644 --- a/src/main/kotlin/fr/shikkanime/entities/Member.kt +++ b/src/main/kotlin/fr/shikkanime/entities/Member.kt @@ -13,8 +13,8 @@ class Member( val creationDateTime: ZonedDateTime = ZonedDateTime.now(), @Column(nullable = false, unique = true) val username: String? = null, - @Column(nullable = false, name = "\"password\"") - val password: ByteArray? = null, + @Column(nullable = false, name = "encrypted_password") + val encryptedPassword: ByteArray? = null, @Column(nullable = false) @Enumerated(EnumType.STRING) val role: Role = Role.GUEST, diff --git a/src/main/kotlin/fr/shikkanime/entities/Metric.kt b/src/main/kotlin/fr/shikkanime/entities/Metric.kt index 991d055b..43024b86 100644 --- a/src/main/kotlin/fr/shikkanime/entities/Metric.kt +++ b/src/main/kotlin/fr/shikkanime/entities/Metric.kt @@ -14,8 +14,6 @@ data class Metric( val cpuLoad: Double = 0.0, @Column(name = "memory_usage") val memoryUsage: Long = 0, - @Column(name = "database_size") - val databaseSize: Long = 0, @Column(nullable = false) val date: ZonedDateTime = ZonedDateTime.now(), ) : ShikkEntity(uuid) diff --git a/src/main/kotlin/fr/shikkanime/entities/SortParameter.kt b/src/main/kotlin/fr/shikkanime/entities/SortParameter.kt new file mode 100644 index 00000000..dcd3ca22 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/entities/SortParameter.kt @@ -0,0 +1,11 @@ +package fr.shikkanime.entities + +data class SortParameter( + val field: String, + val order: Order, +) { + enum class Order { + ASC, + DESC + } +} diff --git a/src/main/kotlin/fr/shikkanime/entities/enums/CountryCode.kt b/src/main/kotlin/fr/shikkanime/entities/enums/CountryCode.kt index 0eebe992..5460bbaa 100644 --- a/src/main/kotlin/fr/shikkanime/entities/enums/CountryCode.kt +++ b/src/main/kotlin/fr/shikkanime/entities/enums/CountryCode.kt @@ -13,5 +13,13 @@ enum class CountryCode(val locale: String? = null, val voice: String? = null) { fun from(string: String): CountryCode { return valueOf(string.uppercase()) } + + fun fromNullable(string: String?): CountryCode? { + return if (string == null) null else try { + valueOf(string.uppercase()) + } catch (e: IllegalArgumentException) { + null + } + } } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/jobs/GCJob.kt b/src/main/kotlin/fr/shikkanime/jobs/GarbageCollectorJob.kt similarity index 64% rename from src/main/kotlin/fr/shikkanime/jobs/GCJob.kt rename to src/main/kotlin/fr/shikkanime/jobs/GarbageCollectorJob.kt index a79a5f3f..85d8c0ae 100644 --- a/src/main/kotlin/fr/shikkanime/jobs/GCJob.kt +++ b/src/main/kotlin/fr/shikkanime/jobs/GarbageCollectorJob.kt @@ -1,7 +1,6 @@ package fr.shikkanime.jobs -class GCJob : AbstractJob() { - +class GarbageCollectorJob : AbstractJob() { override fun run() { System.gc() } diff --git a/src/main/kotlin/fr/shikkanime/jobs/MetricJob.kt b/src/main/kotlin/fr/shikkanime/jobs/MetricJob.kt index f5ed0761..e09aa913 100644 --- a/src/main/kotlin/fr/shikkanime/jobs/MetricJob.kt +++ b/src/main/kotlin/fr/shikkanime/jobs/MetricJob.kt @@ -16,7 +16,6 @@ class MetricJob : AbstractJob() { Metric( cpuLoad = getProcessCPULoad(), memoryUsage = getProcessMemoryUsage(), - databaseSize = metricService.getSize() ) ) } diff --git a/src/main/kotlin/fr/shikkanime/plugins/HTTP.kt b/src/main/kotlin/fr/shikkanime/plugins/HTTP.kt index 480cb7f5..0674a112 100644 --- a/src/main/kotlin/fr/shikkanime/plugins/HTTP.kt +++ b/src/main/kotlin/fr/shikkanime/plugins/HTTP.kt @@ -1,6 +1,7 @@ package fr.shikkanime.plugins import freemarker.cache.ClassTemplateLoader +import io.github.smiley4.ktorswaggerui.SwaggerUI import io.ktor.http.* import io.ktor.serialization.gson.* import io.ktor.server.application.* @@ -27,8 +28,6 @@ fun Application.configureHTTP() { allowMethod(HttpMethod.Delete) allowMethod(HttpMethod.Patch) allowHeader(HttpHeaders.Authorization) - allowHeader("MyCustomHeader") - anyHost() // @TODO: Don't do this in production if possible. Try to limit it. } install(StatusPages) { exception { call, cause -> @@ -42,4 +41,15 @@ fun Application.configureHTTP() { install(FreeMarker) { templateLoader = ClassTemplateLoader(this::class.java.classLoader, "templates") } + install(SwaggerUI) { + swagger { + swaggerUrl = "api/swagger" + forwardRoot = false + } + info { + title = "Shikkanime API" + version = "1.0" + description = "API for testing and demonstration purposes" + } + } } diff --git a/src/main/kotlin/fr/shikkanime/plugins/Routing.kt b/src/main/kotlin/fr/shikkanime/plugins/Routing.kt index fe2551a4..db22223d 100644 --- a/src/main/kotlin/fr/shikkanime/plugins/Routing.kt +++ b/src/main/kotlin/fr/shikkanime/plugins/Routing.kt @@ -5,16 +5,24 @@ import fr.shikkanime.entities.LinkObject import fr.shikkanime.entities.enums.CountryCode import fr.shikkanime.utils.Constant import fr.shikkanime.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 fr.shikkanime.utils.routes.method.Delete +import fr.shikkanime.utils.routes.method.Get +import fr.shikkanime.utils.routes.method.Post +import fr.shikkanime.utils.routes.method.Put +import fr.shikkanime.utils.routes.openapi.OpenAPI +import fr.shikkanime.utils.routes.param.BodyParam +import fr.shikkanime.utils.routes.param.PathParam +import fr.shikkanime.utils.routes.param.QueryParam +import io.github.smiley4.ktorswaggerui.dsl.* +import io.github.smiley4.ktorswaggerui.dsl.get 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.freemarker.* import io.ktor.server.http.content.* +import io.ktor.server.plugins.* import io.ktor.server.plugins.cachingheaders.* import io.ktor.server.request.* import io.ktor.server.response.* @@ -22,12 +30,12 @@ import io.ktor.server.routing.* import io.ktor.server.sessions.* import io.ktor.util.* import java.util.* +import kotlin.collections.set import kotlin.reflect.KFunction -import kotlin.reflect.full.declaredFunctions -import kotlin.reflect.full.findAnnotation -import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.* import kotlin.reflect.jvm.isAccessible import kotlin.reflect.jvm.javaType +import kotlin.reflect.jvm.jvmErasure fun Application.configureRouting() { routing { @@ -48,6 +56,14 @@ fun Routing.createControllerRoutes(controller: Any) { if (controller::class.hasAnnotation()) controller::class.findAnnotation()!!.value else "" val kMethods = controller::class.declaredFunctions.filter { it.hasAnnotation() }.toMutableSet() + route("$prefix/", { + hidden = true + }) { + get { + call.respondRedirect(prefix) + } + } + route(prefix) { kMethods.forEach { method -> val path = method.findAnnotation()!!.value @@ -56,7 +72,9 @@ fun Routing.createControllerRoutes(controller: Any) { val cached = method.findAnnotation()!!.maxAgeSeconds install(CachingHeaders) { - options { _, _ -> io.ktor.http.content.CachingOptions(CacheControl.MaxAge(maxAgeSeconds = cached)) } + options { _, _ -> + CachingOptions(CacheControl.MaxAge(maxAgeSeconds = cached)) + } } } @@ -75,32 +93,93 @@ fun Routing.createControllerRoutes(controller: Any) { } } +private fun swagger( + method: KFunction<*>, + routeTags: List, + hiddenRoute: Boolean +): OpenApiRoute.() -> Unit { + val openApi = method.findAnnotation() ?: return { + tags = routeTags + hidden = hiddenRoute + } + + return { + tags = routeTags + hidden = hiddenRoute + description = openApi.description + request { + method.parameters.filter { it.hasAnnotation() }.forEach { parameter -> + val qp = parameter.findAnnotation()!! + val name = qp.name.ifBlank { parameter.name!! } + val type = if (qp.type == Unit::class) parameter.type.jvmErasure else qp.type + + queryParameter(name, type) { + description = qp.description + required = qp.required + } + } + + method.parameters.filter { it.hasAnnotation() }.forEach { parameter -> + val pp = parameter.findAnnotation()!! + val name = pp.name.ifBlank { parameter.name!! } + val type = if (pp.type == Unit::class) parameter.type.jvmErasure else pp.type + + pathParameter(name, type) { + description = pp.description + required = true + } + } + } + response { + openApi.responses.forEach { response -> + HttpStatusCode.fromValue(response.status) to { + description = response.description + + if (response.type.java.isArray) { + body(BodyTypeDescriptor.multipleOf(response.type.java.componentType.kotlin)) { + mediaType(ContentType(response.contentType.split("/")[0], response.contentType.split("/")[1])) + } + } else { + body(response.type) { + mediaType(ContentType(response.contentType.split("/")[0], response.contentType.split("/")[1])) + } + } + } + } + } + } +} + private fun Route.handleMethods( method: KFunction<*>, prefix: String, controller: Any, path: String, ) { + val routeTags = listOf(controller.javaClass.simpleName.replace("Controller", "")) + val hiddenRoute = !"$prefix$path".startsWith("/api") + val swaggerBuilder = swagger(method, routeTags, hiddenRoute) + if (method.hasAnnotation()) { - get(path) { + get(path, swaggerBuilder) { handleRequest("GET", call, method, prefix, controller, path) } } if (method.hasAnnotation()) { - post(path) { + post(path, swaggerBuilder) { handleRequest("POST", call, method, prefix, controller, path) } } if (method.hasAnnotation()) { - put(path) { + put(path, swaggerBuilder) { handleRequest("PUT", call, method, prefix, controller, path) } } if (method.hasAnnotation()) { - delete(path) { + delete(path, swaggerBuilder) { handleRequest("DELETE", call, method, prefix, controller, path) } } @@ -117,7 +196,7 @@ private suspend fun handleRequest( val parameters = call.parameters.toMap() val replacedPath = replacePathWithParameters("$prefix$path", parameters) - println("$httpMethod $replacedPath") + println("$httpMethod ${call.request.origin.uri} -> $replacedPath") try { val response = callMethodWithParameters(method, controller, call, parameters) @@ -201,25 +280,30 @@ private suspend fun callMethodWithParameters( } kParameter.hasAnnotation() -> { - call.request.queryParameters[kParameter.name!!] + val name = kParameter.findAnnotation()!!.name.ifBlank { kParameter.name!! } + val queryParamValue = call.request.queryParameters[name] + + when (kParameter.type) { + Int::class.starProjectedType.withNullability(true) -> queryParamValue?.toIntOrNull() + String::class.starProjectedType.withNullability(true) -> queryParamValue + CountryCode::class.starProjectedType.withNullability(true) -> CountryCode.fromNullable(queryParamValue) + UUID::class.starProjectedType.withNullability(true) -> if (queryParamValue.isNullOrBlank()) null else UUID.fromString(queryParamValue) + else -> throw Exception("Unknown type ${kParameter.type}") + } } - else -> { - val value = parameters[kParameter.name]!!.first() - - val parsedValue: Any? = when (kParameter.type.javaType) { - UUID::class.java -> UUID.fromString(value) - Int::class.java -> value.toIntOrNull() - CountryCode::class.java -> try { - CountryCode.valueOf(value) - } catch (e: IllegalArgumentException) { - null - } + kParameter.hasAnnotation() -> { + val name = kParameter.findAnnotation()!!.name.ifBlank { kParameter.name!! } + val pathParamValue = parameters[name]?.firstOrNull() - else -> value + when (kParameter.type.javaType) { + UUID::class.java -> UUID.fromString(pathParamValue) + else -> throw Exception("Unknown type ${kParameter.type}") } + } - parsedValue + else -> { + throw Exception("Unknown parameter ${kParameter.name}") } } } diff --git a/src/main/kotlin/fr/shikkanime/plugins/Security.kt b/src/main/kotlin/fr/shikkanime/plugins/Security.kt index 58c2a013..152da372 100644 --- a/src/main/kotlin/fr/shikkanime/plugins/Security.kt +++ b/src/main/kotlin/fr/shikkanime/plugins/Security.kt @@ -3,9 +3,11 @@ package fr.shikkanime.plugins import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm import fr.shikkanime.dtos.MemberDto +import fr.shikkanime.dtos.MessageDto import fr.shikkanime.entities.enums.Role import fr.shikkanime.services.MemberService import fr.shikkanime.utils.Constant +import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.auth.jwt.* @@ -44,7 +46,7 @@ fun Application.configureSecurity() { return@validate session } challenge { - call.respondRedirect("/admin") + call.respond(HttpStatusCode.Unauthorized, MessageDto(MessageDto.Type.ERROR, "You are not authorized to access this page")) } } } diff --git a/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt index c3264cdb..23c27c49 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt @@ -40,7 +40,7 @@ abstract class AbstractRepository { } } - fun find(uuid: UUID): E? { + open fun find(uuid: UUID): E? { return inTransaction { it.find(getEntityClass(), uuid) } diff --git a/src/main/kotlin/fr/shikkanime/repositories/AnimeRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/AnimeRepository.kt index 0ff3abea..76cdceb5 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/AnimeRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/AnimeRepository.kt @@ -1,20 +1,16 @@ package fr.shikkanime.repositories import fr.shikkanime.entities.Anime +import fr.shikkanime.entities.SortParameter import fr.shikkanime.entities.enums.CountryCode import org.hibernate.Hibernate +import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep +import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory import org.hibernate.search.engine.search.query.SearchResult import org.hibernate.search.mapper.orm.Search +import java.util.* class AnimeRepository : AbstractRepository() { - fun preIndex() { - inTransaction { - val searchSession = Search.session(it) - val indexer = searchSession.massIndexer(getEntityClass()) - indexer.startAndWait() - } - } - private fun Anime.initialize(): Anime { Hibernate.initialize(this.simulcasts) return this @@ -25,6 +21,34 @@ class AnimeRepository : AbstractRepository() { return this } + private fun addOrderBy(query: StringBuilder) { + if (!query.contains("ORDER BY")) { + query.append(" ORDER BY") + } + } + + private fun buildSortQuery(sort: List, query: StringBuilder) { + if (sort.any { param -> param.field == "name" }) { + val param = sort.first { param -> param.field == "name" } + addOrderBy(query) + query.append(" LOWER(a.name) ${param.order.name}") + } + + if (sort.any { param -> param.field == "releaseDateTime" }) { + val param = sort.first { param -> param.field == "releaseDateTime" } + addOrderBy(query) + query.append(" a.releaseDateTime ${param.order.name}") + } + } + + fun preIndex() { + inTransaction { + val searchSession = Search.session(it) + val indexer = searchSession.massIndexer(getEntityClass()) + indexer.startAndWait() + } + } + override fun findAll(): List { return inTransaction { it.createQuery("FROM Anime", getEntityClass()) @@ -33,9 +57,22 @@ class AnimeRepository : AbstractRepository() { } } + fun findAll(sort: List, page: Int, limit: Int): List { + return inTransaction { + val query = StringBuilder("FROM Anime a") + buildSortQuery(sort, query) + + it.createQuery(query.toString(), getEntityClass()) + .setFirstResult(limit * page - limit) + .setMaxResults(limit) + .resultList + .initialize() + } + } + fun findByLikeName(countryCode: CountryCode, name: String?): List { return inTransaction { - it.createQuery("FROM Anime where countryCode = :countryCode AND LOWER(name) LIKE :name", getEntityClass()) + it.createQuery("FROM Anime WHERE countryCode = :countryCode AND LOWER(name) LIKE :name", getEntityClass()) .setParameter("countryCode", countryCode) .setParameter("name", "%${name?.lowercase()}%") .resultList @@ -43,19 +80,55 @@ class AnimeRepository : AbstractRepository() { } } - fun findByName(countryCode: CountryCode, name: String?): List { + fun findByName(name: String?, countryCode: CountryCode, page: Int, limit: Int): List { return inTransaction { val searchSession = Search.session(it) val searchResult: SearchResult = searchSession.search(getEntityClass()) - .where { f -> - f.bool() - .must(f.match().field("countryCode").matching(countryCode)) - .must(f.match().field("name").matching(name)) - } - .fetch(20) as SearchResult + .where { w -> findWhere(w, name, countryCode) } + .fetch((page - 1) * limit, limit) as SearchResult searchResult.hits().initialize() } } + + private fun findWhere( + searchPredicateFactory: SearchPredicateFactory, + name: String?, + countryCode: CountryCode + ): BooleanPredicateClausesStep<*>? { + val bool = searchPredicateFactory.bool() + + if (name != null) { + bool.must(searchPredicateFactory.match().field("name").matching(name)) + } + + bool.must(searchPredicateFactory.match().field("countryCode").matching(countryCode)) + return bool + } + + fun findBySimulcast(uuid: UUID, countryCode: CountryCode, sort: List, page: Int, limit: Int): List { + return inTransaction { + val query = StringBuilder("SELECT a FROM Anime a JOIN a.simulcasts s WHERE a.countryCode = :countryCode AND s.uuid = :uuid") + buildSortQuery(sort, query) + + it.createQuery(query.toString(), getEntityClass()) + .setParameter("countryCode", countryCode) + .setParameter("uuid", uuid) + .setFirstResult(limit * page - limit) + .setMaxResults(limit) + .resultList + .initialize() + } + } + + override fun find(uuid: UUID): Anime? { + return inTransaction { + it.createQuery("FROM Anime WHERE uuid = :uuid", getEntityClass()) + .setParameter("uuid", uuid) + .resultList + .firstOrNull() + ?.initialize() + } + } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/repositories/MemberRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/MemberRepository.kt index eab929dd..f1e3694f 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/MemberRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/MemberRepository.kt @@ -14,7 +14,7 @@ class MemberRepository : AbstractRepository() { fun findByUsernameAndPassword(username: String, password: ByteArray): Member? { return inTransaction { - it.createQuery("FROM Member WHERE username = :username AND password = :password", getEntityClass()) + it.createQuery("FROM Member WHERE username = :username AND encryptedPassword = :password", getEntityClass()) .setParameter("username", username) .setParameter("password", password) .resultList diff --git a/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt index 4435e9a9..f5b8a1df 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt @@ -29,10 +29,4 @@ class MetricRepository : AbstractRepository() { .singleResult } } - - fun getSize(): Long { - return inTransaction { - it.createNativeQuery("SELECT pg_database_size('shikkanime')").singleResult as Long - } - } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/services/AbstractService.kt b/src/main/kotlin/fr/shikkanime/services/AbstractService.kt index 64badc55..8161f5b1 100644 --- a/src/main/kotlin/fr/shikkanime/services/AbstractService.kt +++ b/src/main/kotlin/fr/shikkanime/services/AbstractService.kt @@ -7,7 +7,7 @@ import java.util.* abstract class AbstractService> { protected abstract fun getRepository(): R - fun findAll() = getRepository().findAll() + open fun findAll() = getRepository().findAll() fun find(uuid: UUID?) = if (uuid != null) getRepository().find(uuid) else null diff --git a/src/main/kotlin/fr/shikkanime/services/AnimeService.kt b/src/main/kotlin/fr/shikkanime/services/AnimeService.kt index b95d583c..05c6aee4 100644 --- a/src/main/kotlin/fr/shikkanime/services/AnimeService.kt +++ b/src/main/kotlin/fr/shikkanime/services/AnimeService.kt @@ -2,8 +2,10 @@ package fr.shikkanime.services import com.google.inject.Inject import fr.shikkanime.entities.Anime +import fr.shikkanime.entities.SortParameter import fr.shikkanime.entities.enums.CountryCode import fr.shikkanime.repositories.AnimeRepository +import java.util.* class AnimeService : AbstractService() { @Inject @@ -16,6 +18,10 @@ class AnimeService : AbstractService() { return animeRepository } + fun findAll(sort: List, page: Int, limit: Int): List { + return animeRepository.findAll(sort, page, limit) + } + fun preIndex() { animeRepository.preIndex() } @@ -24,8 +30,12 @@ class AnimeService : AbstractService() { return animeRepository.findByLikeName(countryCode, name) } - fun findByName(countryCode: CountryCode, name: String?): List { - return animeRepository.findByName(countryCode, name) + fun findByName(name: String?, countryCode: CountryCode, page: Int, limit: Int): List { + return animeRepository.findByName(name, countryCode, page, limit) + } + + fun findBySimulcast(uuid: UUID, countryCode: CountryCode, sort: List, page: Int, limit: Int): List { + return animeRepository.findBySimulcast(uuid, countryCode, sort, page, limit) } override fun save(entity: Anime): Anime { diff --git a/src/main/kotlin/fr/shikkanime/services/EpisodeService.kt b/src/main/kotlin/fr/shikkanime/services/EpisodeService.kt index 65881f91..123c30b9 100644 --- a/src/main/kotlin/fr/shikkanime/services/EpisodeService.kt +++ b/src/main/kotlin/fr/shikkanime/services/EpisodeService.kt @@ -5,11 +5,11 @@ import fr.shikkanime.entities.Episode import fr.shikkanime.entities.Simulcast import fr.shikkanime.entities.enums.LangType import fr.shikkanime.repositories.EpisodeRepository +import fr.shikkanime.utils.Constant import org.hibernate.Hibernate class EpisodeService : AbstractService() { private val simulcastRange = 10 - private val seasons = listOf("WINTER", "SPRING", "SUMMER", "AUTUMN") @Inject private lateinit var episodeRepository: EpisodeRepository @@ -41,7 +41,7 @@ class EpisodeService : AbstractService() { } val simulcasts = adjustedDates.map { - Simulcast(season = seasons[(it.monthValue - 1) / 3], year = it.year) + Simulcast(season = Constant.seasons[(it.monthValue - 1) / 3], year = it.year) } val previousSimulcast = simulcasts[0] diff --git a/src/main/kotlin/fr/shikkanime/services/ImageService.kt b/src/main/kotlin/fr/shikkanime/services/ImageService.kt index 05670d1b..5c795a1b 100644 --- a/src/main/kotlin/fr/shikkanime/services/ImageService.kt +++ b/src/main/kotlin/fr/shikkanime/services/ImageService.kt @@ -12,6 +12,7 @@ import java.io.ByteArrayOutputStream import java.io.File import java.util.* import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean import javax.imageio.ImageIO import kotlin.system.measureTimeMillis @@ -46,9 +47,10 @@ object ImageService { } } - private val threadPool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() - 1) + private val threadPool = Executors.newFixedThreadPool(2) private val file = File("images-cache.shikk") private var cache = mutableListOf() + private val change = AtomicBoolean(false) private fun toHumanReadable(bytes: Long): String { val kiloByte = 1024L @@ -67,6 +69,7 @@ object ImageService { fun loadCache() { if (!file.exists()) { + change.set(true) saveCache() return } @@ -83,6 +86,11 @@ object ImageService { } fun saveCache() { + if (!change.get()) { + println("No changes detected in images cache") + return + } + if (!file.exists()) { file.createNewFile() } @@ -94,6 +102,7 @@ object ImageService { } println("Saved images cache in $take ms (${toHumanReadable(cache.sumOf { it.originalSize })} -> ${toHumanReadable(cache.sumOf { it.size })})") + change.set(false) } fun add(uuid: UUID, url: String, width: Int, height: Int) { @@ -146,6 +155,7 @@ object ImageService { image.originalSize = bytes.size.toLong() image.size = webp.size.toLong() cache[cache.indexOf(image)] = image + change.set(true) } catch (e: Exception) { println("Failed to load image $url: ${e.message}") e.printStackTrace() diff --git a/src/main/kotlin/fr/shikkanime/services/MemberService.kt b/src/main/kotlin/fr/shikkanime/services/MemberService.kt index b187bc05..9cb72f00 100644 --- a/src/main/kotlin/fr/shikkanime/services/MemberService.kt +++ b/src/main/kotlin/fr/shikkanime/services/MemberService.kt @@ -33,6 +33,6 @@ class MemberService : AbstractService() { val password = RandomManager.generateRandomString(32) println("Default admin password: $password") - save(Member(username = "admin", password = EncryptionManager.generate(password), role = Role.ADMIN)) + save(Member(username = "admin", encryptedPassword = EncryptionManager.generate(password), role = Role.ADMIN)) } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/services/MetricService.kt b/src/main/kotlin/fr/shikkanime/services/MetricService.kt index c13849ce..bd2c7395 100644 --- a/src/main/kotlin/fr/shikkanime/services/MetricService.kt +++ b/src/main/kotlin/fr/shikkanime/services/MetricService.kt @@ -35,8 +35,4 @@ class MetricService : AbstractService() { fun getAverageMemoryUsage(from: ZonedDateTime, to: ZonedDateTime): Double { return averageMemoryUsageCache[FromToZonedDateTimeKeyCache(from, to)] } - - fun getSize(): Long { - return metricRepository.getSize() - } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/services/SimulcastService.kt b/src/main/kotlin/fr/shikkanime/services/SimulcastService.kt index d018d4cd..e207083c 100644 --- a/src/main/kotlin/fr/shikkanime/services/SimulcastService.kt +++ b/src/main/kotlin/fr/shikkanime/services/SimulcastService.kt @@ -3,6 +3,7 @@ package fr.shikkanime.services import com.google.inject.Inject import fr.shikkanime.entities.Simulcast import fr.shikkanime.repositories.SimulcastRepository +import fr.shikkanime.utils.Constant class SimulcastService : AbstractService() { @Inject @@ -12,6 +13,10 @@ class SimulcastService : AbstractService() { return simulcastRepository } + override fun findAll(): List { + return super.findAll().sortedWith(compareBy({ it.year }, { Constant.seasons.indexOf(it.season) })) + } + fun findBySeasonAndYear(season: String, year: Int): Simulcast? { return simulcastRepository.findBySeasonAndYear(season, year) } diff --git a/src/main/kotlin/fr/shikkanime/utils/Constant.kt b/src/main/kotlin/fr/shikkanime/utils/Constant.kt index 33aeb7c9..e9fccf14 100644 --- a/src/main/kotlin/fr/shikkanime/utils/Constant.kt +++ b/src/main/kotlin/fr/shikkanime/utils/Constant.kt @@ -9,10 +9,8 @@ import org.reflections.Reflections object Constant { val reflections = Reflections("fr.shikkanime") val injector: Injector = Guice.createInjector(DefaultModule()) - - val abstractPlatforms = reflections.getSubTypesOf(AbstractPlatform::class.java).map { - injector.getInstance(it) - } + val abstractPlatforms = reflections.getSubTypesOf(AbstractPlatform::class.java).map { injector.getInstance(it) } + val seasons = listOf("WINTER", "SPRING", "SUMMER", "AUTUMN") init { abstractPlatforms.forEach { diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/QueryParam.kt b/src/main/kotlin/fr/shikkanime/utils/routes/QueryParam.kt deleted file mode 100644 index 52eea718..00000000 --- a/src/main/kotlin/fr/shikkanime/utils/routes/QueryParam.kt +++ /dev/null @@ -1,5 +0,0 @@ -package fr.shikkanime.utils.routes - -@Retention(AnnotationRetention.RUNTIME) -@Target(AnnotationTarget.VALUE_PARAMETER) -annotation class QueryParam diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt b/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt index 98dd4934..66eae42f 100644 --- a/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt +++ b/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt @@ -58,5 +58,6 @@ open class Response( fun badRequest(data: Any? = null, session: MemberDto? = null): Response = Response(HttpStatusCode.BadRequest, data = data, session = session) fun notFound(data: Any? = null, session: MemberDto? = null): Response = Response(HttpStatusCode.NotFound, data = data, session = session) + fun conflict(data: Any? = null, session: MemberDto? = null): Response = Response(HttpStatusCode.Conflict, data = data, session = session) } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/method/Delete.kt b/src/main/kotlin/fr/shikkanime/utils/routes/method/Delete.kt index 94fb1faa..fdabe3ed 100644 --- a/src/main/kotlin/fr/shikkanime/utils/routes/method/Delete.kt +++ b/src/main/kotlin/fr/shikkanime/utils/routes/method/Delete.kt @@ -1,4 +1,4 @@ -package fr.ziedelth.utils.routes.method +package fr.shikkanime.utils.routes.method @Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.FUNCTION) diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/method/Get.kt b/src/main/kotlin/fr/shikkanime/utils/routes/method/Get.kt index 529f8165..0776fb6f 100644 --- a/src/main/kotlin/fr/shikkanime/utils/routes/method/Get.kt +++ b/src/main/kotlin/fr/shikkanime/utils/routes/method/Get.kt @@ -1,4 +1,4 @@ -package fr.ziedelth.utils.routes.method +package fr.shikkanime.utils.routes.method @Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.FUNCTION) diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/method/Post.kt b/src/main/kotlin/fr/shikkanime/utils/routes/method/Post.kt index e91da823..2e201b4c 100644 --- a/src/main/kotlin/fr/shikkanime/utils/routes/method/Post.kt +++ b/src/main/kotlin/fr/shikkanime/utils/routes/method/Post.kt @@ -1,4 +1,4 @@ -package fr.ziedelth.utils.routes.method +package fr.shikkanime.utils.routes.method @Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.FUNCTION) diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/method/Put.kt b/src/main/kotlin/fr/shikkanime/utils/routes/method/Put.kt index 4044a979..def7ec79 100644 --- a/src/main/kotlin/fr/shikkanime/utils/routes/method/Put.kt +++ b/src/main/kotlin/fr/shikkanime/utils/routes/method/Put.kt @@ -1,4 +1,4 @@ -package fr.ziedelth.utils.routes.method +package fr.shikkanime.utils.routes.method @Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.FUNCTION) diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/openapi/OpenAPI.kt b/src/main/kotlin/fr/shikkanime/utils/routes/openapi/OpenAPI.kt new file mode 100644 index 00000000..77ca0e81 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/openapi/OpenAPI.kt @@ -0,0 +1,8 @@ +package fr.shikkanime.utils.routes.openapi + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class OpenAPI( + val description: String = "", + val responses: Array = [], +) diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/openapi/OpenAPIResponse.kt b/src/main/kotlin/fr/shikkanime/utils/routes/openapi/OpenAPIResponse.kt new file mode 100644 index 00000000..954910fc --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/openapi/OpenAPIResponse.kt @@ -0,0 +1,12 @@ +package fr.shikkanime.utils.routes.openapi + +import kotlin.reflect.KClass + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class OpenAPIResponse( + val status: Int, + val description: String = "", + val type: KClass<*> = Unit::class, + val contentType: String = "application/json" +) diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/BodyParam.kt b/src/main/kotlin/fr/shikkanime/utils/routes/param/BodyParam.kt similarity index 72% rename from src/main/kotlin/fr/shikkanime/utils/routes/BodyParam.kt rename to src/main/kotlin/fr/shikkanime/utils/routes/param/BodyParam.kt index 0a5d2470..c482dd84 100644 --- a/src/main/kotlin/fr/shikkanime/utils/routes/BodyParam.kt +++ b/src/main/kotlin/fr/shikkanime/utils/routes/param/BodyParam.kt @@ -1,4 +1,4 @@ -package fr.shikkanime.utils.routes +package fr.shikkanime.utils.routes.param @Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.VALUE_PARAMETER) diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/param/PathParam.kt b/src/main/kotlin/fr/shikkanime/utils/routes/param/PathParam.kt new file mode 100644 index 00000000..cbe26560 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/param/PathParam.kt @@ -0,0 +1,11 @@ +package fr.shikkanime.utils.routes.param + +import kotlin.reflect.KClass + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class PathParam( + val name: String = "", + val type: KClass<*> = Unit::class, + val description: String = "", +) diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/param/QueryParam.kt b/src/main/kotlin/fr/shikkanime/utils/routes/param/QueryParam.kt new file mode 100644 index 00000000..2c7eb7d6 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/param/QueryParam.kt @@ -0,0 +1,12 @@ +package fr.shikkanime.utils.routes.param + +import kotlin.reflect.KClass + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class QueryParam( + val name: String = "", + val type: KClass<*> = Unit::class, + val description: String = "", + val required: Boolean = false, +) diff --git a/src/main/resources/assets/js/admin/home_chart.js b/src/main/resources/assets/js/admin/home_chart.js index 9ba94cf9..98e8bfc4 100644 --- a/src/main/resources/assets/js/admin/home_chart.js +++ b/src/main/resources/assets/js/admin/home_chart.js @@ -105,7 +105,4 @@ async function setChartData() { memoryChart.data.datasets[0].data = data.map(metric => metric.memoryUsage); memoryChart.data.datasets[1].data = data.filter(metric => metric.averageMemoryUsage !== "0").map(metric => metric.averageMemoryUsage); memoryChart.update(); - - const lastMetric = data[data.length - 1]; - document.getElementById('dbSize').innerText = lastMetric.databaseSize + ' MB'; } \ No newline at end of file diff --git a/src/main/resources/db/changelog/2023/12/01-changelog.xml b/src/main/resources/db/changelog/2023/12/01-changelog.xml index c85592c8..907d3a9b 100644 --- a/src/main/resources/db/changelog/2023/12/01-changelog.xml +++ b/src/main/resources/db/changelog/2023/12/01-changelog.xml @@ -28,8 +28,6 @@ type="DOUBLE"/> - @@ -69,7 +67,7 @@ + type="VARCHAR(2000)"/> diff --git a/src/main/resources/db/changelog/2023/12/02-changelog.xml b/src/main/resources/db/changelog/2023/12/02-changelog.xml index 2e4e1697..9a538463 100644 --- a/src/main/resources/db/changelog/2023/12/02-changelog.xml +++ b/src/main/resources/db/changelog/2023/12/02-changelog.xml @@ -29,7 +29,7 @@ type="VARCHAR(255)"> - diff --git a/src/main/resources/templates/admin/dashboard.ftl b/src/main/resources/templates/admin/dashboard.ftl index 745b91b7..a3423008 100644 --- a/src/main/resources/templates/admin/dashboard.ftl +++ b/src/main/resources/templates/admin/dashboard.ftl @@ -1,8 +1,6 @@ <#import "_navigation.ftl" as navigation /> <@navigation.display> -
Database current size:
-
diff --git a/src/test/kotlin/fr/shikkanime/controllers/api/MetricControllerTest.kt b/src/test/kotlin/fr/shikkanime/controllers/api/MetricControllerTest.kt new file mode 100644 index 00000000..b7e1d415 --- /dev/null +++ b/src/test/kotlin/fr/shikkanime/controllers/api/MetricControllerTest.kt @@ -0,0 +1,58 @@ +package fr.shikkanime.controllers.api + +import fr.shikkanime.module +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.server.testing.* +import io.ktor.util.* +import kotlin.test.Test +import kotlin.test.assertEquals + +class MetricControllerTest { + @Test + fun `get metrics unauthorized`() { + testApplication { + application { + module() + } + + client.get("/api/metrics").apply { + assertEquals(HttpStatusCode.Unauthorized, status) + } + } + } + + @OptIn(InternalAPI::class) + @Test + fun `get metrics authorized`() { + testApplication { + application { + module() + } + + val client = HttpClient(MockEngine) { + engine { + addHandler { request -> + when (request.url.encodedPath) { + "/api/metrics" -> { + respond( + content = "[]", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + + else -> error("Unhandled ${request.url.encodedPath}") + } + } + } + } + + client.get("/api/metrics").apply { + assertEquals(HttpStatusCode.OK, status) + } + } + } +} \ No newline at end of file