feat(server): add REST-API endpoints for inventory sync & CRUD

server/src/main/kotlin/.../routes/InventoryRoutes.kt:
- GET /api/inventory: returns full inventory as JSON
- PUT /api/inventory: full-sync replaces entire server inventory

server/src/main/kotlin/.../plugins/StatusPages.kt:
- Structured error handling via Ktor StatusPages plugin
- BadRequestException, SerializationException, IllegalArgumentException → 400
- Unhandled exceptions → 500 with logging

server/src/main/kotlin/.../plugins/CallLogging.kt:
- Request logging via Ktor CallLogging plugin (INFO level)

server/src/main/kotlin/.../model/ErrorResponse.kt:
- Serializable error response DTO (status + message)

server/src/main/kotlin/.../plugins/Routing.kt:
- Health endpoint moved from /health to /api/health
- Inventory routes mounted under /api/inventory

server/src/main/kotlin/.../Application.kt:
- Added configurePlugins() for testability (DB init separate)
- StatusPages and CallLogging plugins configured

server/src/test/.../ApplicationTest.kt:
- 8 endpoint tests using Ktor TestApplication with in-memory H2
- Tests: health, 404, empty GET, PUT valid, PUT+GET roundtrip,
  invalid JSON → 400, data replacement, JSON content type

Closes #42
This commit is contained in:
Jens Reinemann 2026-05-14 20:30:34 +02:00
parent 2387c6ee5a
commit 5974144621
9 changed files with 282 additions and 13 deletions

View file

@ -54,6 +54,8 @@ ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-server-config-yaml = { group = "io.ktor", name = "ktor-server-config-yaml", version.ref = "ktor" }
ktor-server-status-pages = { group = "io.ktor", name = "ktor-server-status-pages", version.ref = "ktor" }
ktor-server-call-logging = { group = "io.ktor", name = "ktor-server-call-logging", version.ref = "ktor" }
ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" }

View file

@ -31,6 +31,8 @@ dependencies {
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.server.status.pages)
implementation(libs.ktor.server.call.logging)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.logback.classic)
implementation(libs.exposed.core)

View file

@ -1,8 +1,10 @@
package de.krisenvorrat.server
import de.krisenvorrat.server.db.DatabaseFactory
import de.krisenvorrat.server.plugins.configureCallLogging
import de.krisenvorrat.server.plugins.configureRouting
import de.krisenvorrat.server.plugins.configureSerialization
import de.krisenvorrat.server.plugins.configureStatusPages
import io.ktor.server.application.*
import io.ktor.server.netty.*
@ -12,6 +14,12 @@ fun main(args: Array<String>) {
internal fun Application.module() {
DatabaseFactory.init()
configurePlugins()
}
internal fun Application.configurePlugins() {
configureSerialization()
configureStatusPages()
configureCallLogging()
configureRouting()
}

View file

@ -0,0 +1,9 @@
package de.krisenvorrat.server.model
import kotlinx.serialization.Serializable
@Serializable
internal data class ErrorResponse(
val status: Int,
val message: String
)

View file

@ -0,0 +1,11 @@
package de.krisenvorrat.server.plugins
import io.ktor.server.application.*
import io.ktor.server.plugins.calllogging.*
import org.slf4j.event.Level
internal fun Application.configureCallLogging() {
install(CallLogging) {
level = Level.INFO
}
}

View file

@ -1,14 +1,17 @@
package de.krisenvorrat.server.plugins
import de.krisenvorrat.server.repository.InventoryRepository
import de.krisenvorrat.server.routes.inventoryRoutes
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
internal fun Application.configureRouting() {
internal fun Application.configureRouting(repository: InventoryRepository = InventoryRepository()) {
routing {
get("/health") {
get("/api/health") {
call.respondText("OK", ContentType.Text.Plain, HttpStatusCode.OK)
}
inventoryRoutes(repository)
}
}

View file

@ -0,0 +1,51 @@
package de.krisenvorrat.server.plugins
import de.krisenvorrat.server.model.ErrorResponse
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
import kotlinx.serialization.SerializationException
internal fun Application.configureStatusPages() {
install(StatusPages) {
exception<BadRequestException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(
status = HttpStatusCode.BadRequest.value,
message = "Invalid request body: ${cause.cause?.message ?: cause.message}"
)
)
}
exception<SerializationException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(
status = HttpStatusCode.BadRequest.value,
message = "Invalid request body: ${cause.message}"
)
)
}
exception<IllegalArgumentException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(
status = HttpStatusCode.BadRequest.value,
message = cause.message ?: "Bad request"
)
)
}
exception<Throwable> { call, cause ->
call.application.log.error("Unhandled exception", cause)
call.respond(
HttpStatusCode.InternalServerError,
ErrorResponse(
status = HttpStatusCode.InternalServerError.value,
message = "Internal server error"
)
)
}
}
}

View file

@ -0,0 +1,24 @@
package de.krisenvorrat.server.routes
import de.krisenvorrat.server.repository.InventoryRepository
import de.krisenvorrat.shared.model.InventoryDto
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
internal fun Route.inventoryRoutes(repository: InventoryRepository) {
route("/api/inventory") {
get {
val inventory = repository.loadInventory()
call.respond(HttpStatusCode.OK, inventory)
}
put {
val inventory = call.receive<InventoryDto>()
repository.saveInventory(inventory)
val saved = repository.loadInventory()
call.respond(HttpStatusCode.OK, saved)
}
}
}

View file

@ -1,22 +1,42 @@
package de.krisenvorrat.server
import de.krisenvorrat.server.db.DatabaseFactory
import de.krisenvorrat.server.model.ErrorResponse
import de.krisenvorrat.shared.model.CategoryDto
import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto
import de.krisenvorrat.shared.model.LocationDto
import de.krisenvorrat.shared.model.SettingDto
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ApplicationTest {
@Test
fun test_healthEndpoint_returnsOk() = testApplication {
application {
module()
}
private val json = Json {
ignoreUnknownKeys = true
}
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver"
)
configurePlugins()
}
block()
}
@Test
fun test_healthEndpoint_returnsOk() = testApp {
// When
val response = client.get("/health")
val response = client.get("/api/health")
// Then
assertEquals(HttpStatusCode.OK, response.status)
@ -24,15 +44,154 @@ class ApplicationTest {
}
@Test
fun test_unknownRoute_returns404() = testApplication {
application {
module()
}
fun test_unknownRoute_returns404() = testApp {
// When
val response = client.get("/nonexistent")
// Then
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Test
fun test_getInventory_emptyDatabase_returnsEmptyInventory() = testApp {
// When
val response = client.get("/api/inventory")
// Then
assertEquals(HttpStatusCode.OK, response.status)
val inventory = json.decodeFromString<InventoryDto>(response.bodyAsText())
assertTrue(inventory.categories.isEmpty())
assertTrue(inventory.locations.isEmpty())
assertTrue(inventory.items.isEmpty())
assertTrue(inventory.settings.isEmpty())
}
@Test
fun test_putInventory_validData_returnsUpdatedInventory() = testApp {
// Given
val inventory = createTestInventory()
val body = json.encodeToString(InventoryDto.serializer(), inventory)
// When
val response = client.put("/api/inventory") {
contentType(ContentType.Application.Json)
setBody(body)
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<InventoryDto>(response.bodyAsText())
assertEquals(2, result.categories.size)
assertEquals("Konserven", result.categories[0].name)
assertEquals(1, result.items.size)
assertEquals("Dosenbrot", result.items[0].name)
}
@Test
fun test_putThenGet_roundTripsCorrectly() = testApp {
// Given
val inventory = createTestInventory()
val body = json.encodeToString(InventoryDto.serializer(), inventory)
// When PUT
client.put("/api/inventory") {
contentType(ContentType.Application.Json)
setBody(body)
}
// When GET
val response = client.get("/api/inventory")
// Then
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<InventoryDto>(response.bodyAsText())
assertEquals(2, result.categories.size)
assertEquals(2, result.locations.size)
assertEquals(1, result.items.size)
assertEquals(1, result.settings.size)
assertEquals("item-1", result.items[0].id)
}
@Test
fun test_putInventory_invalidJson_returns400() = testApp {
// When
val response = client.put("/api/inventory") {
contentType(ContentType.Application.Json)
setBody("{invalid json}")
}
// Then
assertEquals(HttpStatusCode.BadRequest, response.status)
val error = json.decodeFromString<ErrorResponse>(response.bodyAsText())
assertEquals(400, error.status)
assertTrue(error.message.contains("Invalid request body"))
}
@Test
fun test_putInventory_replacesExistingData() = testApp {
// Given first PUT
val firstInventory = createTestInventory()
client.put("/api/inventory") {
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), firstInventory))
}
// When second PUT with different data
val updatedInventory = InventoryDto(
categories = listOf(CategoryDto(id = 10, name = "Neu")),
locations = listOf(LocationDto(id = 10, name = "Garage")),
items = emptyList(),
settings = listOf(SettingDto(key = "lang", value = "de"))
)
val response = client.put("/api/inventory") {
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), updatedInventory))
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<InventoryDto>(response.bodyAsText())
assertEquals(1, result.categories.size)
assertEquals("Neu", result.categories[0].name)
assertEquals(0, result.items.size)
}
@Test
fun test_getInventory_returnsJsonContentType() = testApp {
// When
val response = client.get("/api/inventory")
// Then
assertEquals(ContentType.Application.Json.withCharset(Charsets.UTF_8), response.contentType())
}
private fun createTestInventory(): InventoryDto = InventoryDto(
categories = listOf(
CategoryDto(id = 1, name = "Konserven"),
CategoryDto(id = 2, name = "Getränke")
),
locations = listOf(
LocationDto(id = 1, name = "Keller"),
LocationDto(id = 2, name = "Speisekammer")
),
items = listOf(
ItemDto(
id = "item-1",
name = "Dosenbrot",
categoryId = 1,
quantity = 5.0,
unit = "Stück",
unitPrice = 3.99,
kcalPer100g = 250,
expiryDate = "2027-06-15",
locationId = 1,
minStock = 2.0,
notes = "Vollkornbrot in der Dose",
lastUpdated = 1715000000L
)
),
settings = listOf(
SettingDto(key = "theme", value = "dark")
)
)
}