From 59741446219f28e45cf21b5c8ab677e9ed2a12cc Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Thu, 14 May 2026 20:30:34 +0200 Subject: [PATCH] feat(server): add REST-API endpoints for inventory sync & CRUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- gradle/libs.versions.toml | 2 + server/build.gradle.kts | 2 + .../de/krisenvorrat/server/Application.kt | 8 + .../server/model/ErrorResponse.kt | 9 + .../server/plugins/CallLogging.kt | 11 ++ .../de/krisenvorrat/server/plugins/Routing.kt | 7 +- .../server/plugins/StatusPages.kt | 51 +++++ .../server/routes/InventoryRoutes.kt | 24 +++ .../de/krisenvorrat/server/ApplicationTest.kt | 181 ++++++++++++++++-- 9 files changed, 282 insertions(+), 13 deletions(-) create mode 100644 server/src/main/kotlin/de/krisenvorrat/server/model/ErrorResponse.kt create mode 100644 server/src/main/kotlin/de/krisenvorrat/server/plugins/CallLogging.kt create mode 100644 server/src/main/kotlin/de/krisenvorrat/server/plugins/StatusPages.kt create mode 100644 server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 529d19d..025c6fa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index dd3db3d..d1c0548 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -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) diff --git a/server/src/main/kotlin/de/krisenvorrat/server/Application.kt b/server/src/main/kotlin/de/krisenvorrat/server/Application.kt index 4b54a2f..a688fc5 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/Application.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/Application.kt @@ -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) { internal fun Application.module() { DatabaseFactory.init() + configurePlugins() +} + +internal fun Application.configurePlugins() { configureSerialization() + configureStatusPages() + configureCallLogging() configureRouting() } diff --git a/server/src/main/kotlin/de/krisenvorrat/server/model/ErrorResponse.kt b/server/src/main/kotlin/de/krisenvorrat/server/model/ErrorResponse.kt new file mode 100644 index 0000000..22667d1 --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/model/ErrorResponse.kt @@ -0,0 +1,9 @@ +package de.krisenvorrat.server.model + +import kotlinx.serialization.Serializable + +@Serializable +internal data class ErrorResponse( + val status: Int, + val message: String +) diff --git a/server/src/main/kotlin/de/krisenvorrat/server/plugins/CallLogging.kt b/server/src/main/kotlin/de/krisenvorrat/server/plugins/CallLogging.kt new file mode 100644 index 0000000..a1613a1 --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/plugins/CallLogging.kt @@ -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 + } +} diff --git a/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt b/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt index 39ae70f..313af34 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt @@ -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) } } diff --git a/server/src/main/kotlin/de/krisenvorrat/server/plugins/StatusPages.kt b/server/src/main/kotlin/de/krisenvorrat/server/plugins/StatusPages.kt new file mode 100644 index 0000000..33380a3 --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/plugins/StatusPages.kt @@ -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 { call, cause -> + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse( + status = HttpStatusCode.BadRequest.value, + message = "Invalid request body: ${cause.cause?.message ?: cause.message}" + ) + ) + } + exception { call, cause -> + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse( + status = HttpStatusCode.BadRequest.value, + message = "Invalid request body: ${cause.message}" + ) + ) + } + exception { call, cause -> + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse( + status = HttpStatusCode.BadRequest.value, + message = cause.message ?: "Bad request" + ) + ) + } + exception { call, cause -> + call.application.log.error("Unhandled exception", cause) + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse( + status = HttpStatusCode.InternalServerError.value, + message = "Internal server error" + ) + ) + } + } +} diff --git a/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt b/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt new file mode 100644 index 0000000..a44f205 --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt @@ -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() + repository.saveInventory(inventory) + val saved = repository.loadInventory() + call.respond(HttpStatusCode.OK, saved) + } + } +} diff --git a/server/src/test/kotlin/de/krisenvorrat/server/ApplicationTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/ApplicationTest.kt index 7ce8d99..4778e19 100644 --- a/server/src/test/kotlin/de/krisenvorrat/server/ApplicationTest.kt +++ b/server/src/test/kotlin/de/krisenvorrat/server/ApplicationTest.kt @@ -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(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(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(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(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(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") + ) + ) }