Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ out/
/Anime/
/backups/
/data/
/logs/
logs/
.hermes/
4 changes: 1 addition & 3 deletions admin/src/main/kotlin/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ fun main() {
configureDefaultModules()

routing {
route("/api") {
configureSwaggerRoute("Admin API", "1.0-SNAPSHOT")
}
configureSwaggerRoute("Admin API", "1.0-SNAPSHOT")

ControllerBinder.register(
this@routing,
Expand Down
10 changes: 9 additions & 1 deletion admin/src/main/kotlin/controllers/UserController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,20 @@ import org.koin.core.annotation.Single
import java.util.logging.Level

@Single(binds = [IController::class])
@RestController("/api/users")
@RestController("/v1/users")
class UserController(
private val loginAdminUseCase: LoginAdminUserUseCase
) : IController {
private val logger = LoggerFactory.getLogger()

/**
* Logs an admin user in.
*
* @param request login request carrying the username and password of the admin user.
* @return `200` when the credentials are valid, `400` when they are not.
* @see [LoginAdminUserUseCase]
* @see [UserLoginDtoRequest]
*/
@PostMapping("/login")
@Operation(
summary = "Login an admin user",
Expand Down
6 changes: 6 additions & 0 deletions api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
plugins {
id("buildsrc.convention.kotlin-jvm")
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.shikkanimeFrameworkKoin)
application
}

Expand All @@ -11,8 +13,12 @@ dependencies {
implementation(project(":models"))
implementation(project(":database"))
implementation(libs.shikkanimeFrameworkCore)
implementation(libs.shikkanimeFrameworkKoin)
implementation(libs.shikkanimeFrameworkKtor)

testImplementation(kotlin("test"))
testImplementation(libs.bundles.testEcosystem)
testImplementation(libs.shikkanimeFrameworkKtorTest)
testImplementation(testFixtures(project(":database")))
}

24 changes: 0 additions & 24 deletions api/src/main/kotlin/ApiApplication.kt

This file was deleted.

58 changes: 58 additions & 0 deletions api/src/main/kotlin/Application.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package fr.shikkanime.api

import fr.shikkanime.database.DatabaseManager
import fr.shikkanime.database.DatabaseModule
import fr.shikkanime.framework.koin.applyTransactionalProxies
import fr.shikkanime.ktor.ControllerBinder
import fr.shikkanime.ktor.configureSwaggerRoute
import fr.shikkanime.ktor.IController
import fr.shikkanime.ktor.configureDefaultModules
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import io.ktor.server.routing.*
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.KoinApplication
import org.koin.core.annotation.Module
import org.koin.plugin.module.dsl.startKoin

@Module
@ComponentScan("fr.shikkanime.api")
class AppModule

@KoinApplication(modules = [AppModule::class, DatabaseModule::class])
class MyApp

/**
* Starts the Shikkanime REST API.
*
* Connects the database, boots the application graph with its transactional proxies, then serves
* the API on the `PORT` environment variable (8080 when unset) until the process is stopped.
*
* @return nothing; the embedded server blocks the calling thread until it is stopped.
* @see [MyApp]
* @see [DatabaseManager]
*/
fun main() {
DatabaseManager.connect()

val koin = startKoin<MyApp>().koin
applyTransactionalProxies(koin)

val port =
System.getenv("PORT")?.toIntOrNull()
?: 8080

embeddedServer(CIO, port = port) {
configureDefaultModules()

routing {
configureSwaggerRoute("Shikkanime API", "1.0.0")

ControllerBinder.register(
this@routing,
koin.getAll<IController>()
)
}
}
.start(wait = true)
}
71 changes: 71 additions & 0 deletions api/src/main/kotlin/controllers/AnimeController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package fr.shikkanime.api.controllers

import fr.shikkanime.api.dtos.AnimeDto
import fr.shikkanime.api.dtos.toDto
import fr.shikkanime.database.services.AnimeService
import fr.shikkanime.models.AnimeWithChildren
import fr.shikkanime.ktor.ApiResponse
import fr.shikkanime.ktor.ApiResponses
import fr.shikkanime.ktor.GetMapping
import fr.shikkanime.ktor.IController
import fr.shikkanime.ktor.Operation
import fr.shikkanime.ktor.PathParam
import fr.shikkanime.ktor.ResponseEntity
import fr.shikkanime.ktor.RestController
import fr.shikkanime.ktor.dtos.ErrorMessageDto
import fr.shikkanime.ktor.dtos.MessageDto
import org.koin.core.annotation.Single
import kotlin.uuid.Uuid

/**
* Read-only REST API on the stored anime aggregates.
*/
@Suppress("unused")
@RestController("/v1/animes")
@Single(binds = [IController::class])
class AnimeController(
private val animeService: AnimeService
) : IController {
/**
* Lists every stored anime with its children.
*
* @return the anime catalog as DTOs.
* @see [AnimeService.findAll]
*/
@GetMapping
@Operation(
"List stored animes",
description = "Returns every stored anime, with its localizations, sources and artwork.",
tags = ["Animes"]
)
@ApiResponses([
ApiResponse(200, "The stored animes", List::class, [AnimeDto::class])
])
fun findAll(): List<AnimeDto> =
// The whole catalog is asked in one read, the mapping is only a shape change
animeService.findAll()
.map(AnimeWithChildren::toDto)

/**
* Loads one anime by its identifier.
*
* @param id identifier of the anime to load.
* @return the anime with its children, or a 404 when it does not exist.
* @see [AnimeService.findById]
*/
@GetMapping("/{id}")
@Operation(
"Get a stored anime",
description = "Returns one stored anime, with its localizations, sources and artwork.",
tags = ["Animes"]
)
@ApiResponses([
ApiResponse(200, "The stored anime", AnimeDto::class),
ApiResponse(404, "No anime with this identifier", ErrorMessageDto::class)
])
fun findById(@PathParam("id") id: Uuid): ResponseEntity<*> =
// Load the stored aggregate of the requested identifier
animeService.findById(id)
?.let { ResponseEntity.ok(it.toDto()) }
?: ResponseEntity.notFound(MessageDto.error("No anime with identifier $id"))
}
40 changes: 40 additions & 0 deletions api/src/main/kotlin/dtos/AnimeDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package fr.shikkanime.api.dtos

import fr.shikkanime.models.AnimeLocalization
import fr.shikkanime.models.AnimePlatform
import fr.shikkanime.models.AnimeWithChildren
import fr.shikkanime.models.Image
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid

/**
* REST read model of a stored anime, without any persistence metadata.
*
* Served by the REST controllers of the anime routes: one DTO shapes the answer of the reads,
* whatever the caller does with it.
*/
@Serializable
data class AnimeDto(
val id: Uuid,
val localizations: List<AnimeLocalizationDto>,
val platforms: List<AnimePlatformDto>,
val images: List<ImageDto>
)

/**
* Maps a stored anime aggregate to its API view.
*
* Audit columns are deliberately left out of the payload.
*
* @receiver stored anime with the rows it owns.
* @return the API view of this anime, with its localizations, sources and artwork.
* @see [AnimeDto]
* @see [AnimeWithChildren]
*/
fun AnimeWithChildren.toDto(): AnimeDto =
AnimeDto(
id = requireNotNull(anime.id),
localizations = localizations.map(AnimeLocalization::toDto),
platforms = platforms.map(AnimePlatform::toDto),
images = images.map(Image::toDto)
)
29 changes: 29 additions & 0 deletions api/src/main/kotlin/dtos/AnimeLocalizationDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package fr.shikkanime.api.dtos

import fr.shikkanime.models.AnimeLocalization
import fr.shikkanime.models.Locale
import kotlinx.serialization.Serializable

/**
* API view of a localized editorial text.
*/
@Serializable
data class AnimeLocalizationDto(
val locale: Locale,
val title: String,
val description: String?
)

/**
* Maps a stored localization to its API view.
*
* @receiver stored localization.
* @return the API view of this localization.
* @see [AnimeLocalizationDto]
*/
fun AnimeLocalization.toDto(): AnimeLocalizationDto =
AnimeLocalizationDto(
locale = locale,
title = title,
description = description
)
27 changes: 27 additions & 0 deletions api/src/main/kotlin/dtos/AnimePlatformDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package fr.shikkanime.api.dtos

import fr.shikkanime.models.AnimePlatform
import fr.shikkanime.models.Platform
import kotlinx.serialization.Serializable

/**
* API view of a platform source link.
*/
@Serializable
data class AnimePlatformDto(
val platform: Platform,
val platformId: String
)

/**
* Maps a stored platform source link to its API view.
*
* @receiver stored platform source link.
* @return the API view of this source link.
* @see [AnimePlatformDto]
*/
fun AnimePlatform.toDto(): AnimePlatformDto =
AnimePlatformDto(
platform = platform,
platformId = platformId
)
27 changes: 27 additions & 0 deletions api/src/main/kotlin/dtos/ImageDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package fr.shikkanime.api.dtos

import fr.shikkanime.models.Image
import fr.shikkanime.models.ImageType
import kotlinx.serialization.Serializable

/**
* API view of an artwork row.
*/
@Serializable
data class ImageDto(
val type: ImageType,
val url: String
)

/**
* Maps a stored artwork row to its API view.
*
* @receiver stored artwork row.
* @return the API view of this artwork row.
* @see [ImageDto]
*/
fun Image.toDto(): ImageDto =
ImageDto(
type = type,
url = url
)
Loading
Loading