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:
parent
2387c6ee5a
commit
5974144621
9 changed files with 282 additions and 13 deletions
|
|
@ -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-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-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-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" }
|
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" }
|
logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }
|
||||||
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" }
|
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" }
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ dependencies {
|
||||||
implementation(libs.ktor.server.core)
|
implementation(libs.ktor.server.core)
|
||||||
implementation(libs.ktor.server.netty)
|
implementation(libs.ktor.server.netty)
|
||||||
implementation(libs.ktor.server.content.negotiation)
|
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.ktor.serialization.kotlinx.json)
|
||||||
implementation(libs.logback.classic)
|
implementation(libs.logback.classic)
|
||||||
implementation(libs.exposed.core)
|
implementation(libs.exposed.core)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package de.krisenvorrat.server
|
package de.krisenvorrat.server
|
||||||
|
|
||||||
import de.krisenvorrat.server.db.DatabaseFactory
|
import de.krisenvorrat.server.db.DatabaseFactory
|
||||||
|
import de.krisenvorrat.server.plugins.configureCallLogging
|
||||||
import de.krisenvorrat.server.plugins.configureRouting
|
import de.krisenvorrat.server.plugins.configureRouting
|
||||||
import de.krisenvorrat.server.plugins.configureSerialization
|
import de.krisenvorrat.server.plugins.configureSerialization
|
||||||
|
import de.krisenvorrat.server.plugins.configureStatusPages
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.netty.*
|
import io.ktor.server.netty.*
|
||||||
|
|
||||||
|
|
@ -12,6 +14,12 @@ fun main(args: Array<String>) {
|
||||||
|
|
||||||
internal fun Application.module() {
|
internal fun Application.module() {
|
||||||
DatabaseFactory.init()
|
DatabaseFactory.init()
|
||||||
|
configurePlugins()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Application.configurePlugins() {
|
||||||
configureSerialization()
|
configureSerialization()
|
||||||
|
configureStatusPages()
|
||||||
|
configureCallLogging()
|
||||||
configureRouting()
|
configureRouting()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package de.krisenvorrat.server.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class ErrorResponse(
|
||||||
|
val status: Int,
|
||||||
|
val message: String
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
package de.krisenvorrat.server.plugins
|
package de.krisenvorrat.server.plugins
|
||||||
|
|
||||||
|
import de.krisenvorrat.server.repository.InventoryRepository
|
||||||
|
import de.krisenvorrat.server.routes.inventoryRoutes
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
|
||||||
internal fun Application.configureRouting() {
|
internal fun Application.configureRouting(repository: InventoryRepository = InventoryRepository()) {
|
||||||
routing {
|
routing {
|
||||||
get("/health") {
|
get("/api/health") {
|
||||||
call.respondText("OK", ContentType.Text.Plain, HttpStatusCode.OK)
|
call.respondText("OK", ContentType.Text.Plain, HttpStatusCode.OK)
|
||||||
}
|
}
|
||||||
|
inventoryRoutes(repository)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,42 @@
|
||||||
package de.krisenvorrat.server
|
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.request.*
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.testing.*
|
import io.ktor.server.testing.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ApplicationTest {
|
class ApplicationTest {
|
||||||
|
|
||||||
@Test
|
private val json = Json {
|
||||||
fun test_healthEndpoint_returnsOk() = testApplication {
|
ignoreUnknownKeys = true
|
||||||
application {
|
}
|
||||||
module()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
// When
|
||||||
val response = client.get("/health")
|
val response = client.get("/api/health")
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertEquals(HttpStatusCode.OK, response.status)
|
assertEquals(HttpStatusCode.OK, response.status)
|
||||||
|
|
@ -24,15 +44,154 @@ class ApplicationTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_unknownRoute_returns404() = testApplication {
|
fun test_unknownRoute_returns404() = testApp {
|
||||||
application {
|
|
||||||
module()
|
|
||||||
}
|
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val response = client.get("/nonexistent")
|
val response = client.get("/nonexistent")
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertEquals(HttpStatusCode.NotFound, response.status)
|
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")
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue