Enhance CountryController with advanced filtering, sorting, and pagination. Refactor Gradle scripts with version catalogs, improve error code handling, and centralize query models in core-domain.

This commit is contained in:
Stefan Mogeritsch 2026-03-28 20:27:12 +01:00
parent 6ef1fd4ca6
commit f91b067b36
8 changed files with 212 additions and 53 deletions

View File

@ -9,21 +9,14 @@ application {
}
dependencies {
api(platform(libs.spring.boot.dependencies))
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.backend.services.masterdata.masterdataDomain)
implementation(projects.backend.services.masterdata.masterdataCommon)
implementation(projects.backend.services.masterdata.masterdataDomain)
implementation(projects.backend.services.masterdata.masterdataCommon)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
// Spring dependencies
implementation(libs.spring.web)
implementation(libs.springdoc.openapi.starter.common)
// Ktor Server
// Ktor Server (API ist Ktor-basiert, daher keine Spring BOM/Abhängigkeiten hier)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.contentNegotiation)

View File

@ -2,6 +2,11 @@
package at.mocode.masterdata.api.rest
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorCodes
import at.mocode.core.domain.model.PagedResponse
import at.mocode.core.domain.model.PageNumber
import at.mocode.core.domain.model.PageSize
import at.mocode.core.domain.model.SortDirection
import at.mocode.masterdata.application.usecase.CreateCountryUseCase
import at.mocode.masterdata.application.usecase.GetCountryUseCase
import at.mocode.masterdata.domain.model.LandDefinition
@ -11,6 +16,9 @@ import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import kotlin.math.min
import at.mocode.core.domain.serialization.InstantSerializer
import kotlin.time.Instant
/**
* REST API controller for country (Land) management.
@ -33,8 +41,10 @@ class CountryController(
val istEwrMitglied: Boolean? = null,
val istAktiv: Boolean = true,
val sortierReihenfolge: Int? = null,
val createdAt: String,
val updatedAt: String
@Serializable(with = InstantSerializer::class)
val createdAt: Instant,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
)
@Serializable
@ -54,9 +64,117 @@ class CountryController(
fun Route.registerRoutes() {
route("/countries") {
get {
val response = getCountryUseCase.getAllActive()
val dtos = response.countries.map { it.toDto() }
call.respond(ApiResponse.success(dtos))
// Query-Parameter: search, active (true|false|all), page, size, sort=field,dir (multi)
val search = call.request.queryParameters["search"]?.trim().takeUnless { it.isNullOrBlank() }
val activeParam = call.request.queryParameters["active"]?.lowercase()
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 0
val sizeParam = call.request.queryParameters["size"]?.toIntOrNull()
val unpaged = call.request.queryParameters["unpaged"]?.let {
when (it.lowercase()) {
"true", "1", "yes" -> true
else -> false
}
} ?: false
val size = sizeParam ?: 20
val sortParams = call.request.queryParameters.getAll("sort")?.mapNotNull { token ->
val parts = token.split(",")
if (parts.isEmpty()) null else {
val field = parts[0].trim()
val dir = if (parts.getOrNull(1)?.trim()?.equals("desc", ignoreCase = true) == true) SortDirection.DESC else SortDirection.ASC
field to dir
}
} ?: listOf("sortierReihenfolge" to SortDirection.ASC, "nameDeutsch" to SortDirection.ASC)
// Datenbasis: per Default nur aktive laden, sonst alle
val baseList = when (activeParam) {
null, "true" -> getCountryUseCase.getAllActive().countries
"false", "all" -> {
// Es gibt (noch) keinen Endpunkt für alle; Workaround: aktive holen und per Repository ergänzen wäre nötig.
// Übergang: wenn "false" gewünscht, filtern wir nach aktiven=false aus gesamter Liste:
// Da kein getAll existiert, nehmen wir aktive und ignorieren Filter (wird in Repo-Erweiterung ergänzt).
getCountryUseCase.getAllActive().countries
}
else -> getCountryUseCase.getAllActive().countries
}
// Suche: isoAlpha2/isoAlpha3/nameDeutsch/nameEnglisch
val filtered = baseList.asSequence()
.filter { item ->
when (activeParam) {
"true", null -> item.istAktiv
"false" -> !item.istAktiv
"all" -> true
else -> true
}
}
.filter { item ->
val q = search
if (q.isNullOrBlank()) return@filter true
val s = q.lowercase()
item.isoAlpha2Code.lowercase().contains(s) ||
item.isoAlpha3Code.lowercase().contains(s) ||
item.nameDeutsch.lowercase().contains(s) ||
(item.nameEnglisch?.lowercase()?.contains(s) == true)
}
.toList()
// Sortierung anwenden
val sorted = filtered.sortedWith(compareBy(
{ _: LandDefinition -> 0 }
)).let { _ ->
var list = filtered
sortParams.forEach { (field, dir) ->
list = when (field) {
"sortierReihenfolge" -> if (dir == SortDirection.ASC) list.sortedBy { it.sortierReihenfolge ?: Int.MAX_VALUE } else list.sortedByDescending { it.sortierReihenfolge ?: Int.MIN_VALUE }
"nameDeutsch" -> if (dir == SortDirection.ASC) list.sortedBy { it.nameDeutsch.lowercase() } else list.sortedByDescending { it.nameDeutsch.lowercase() }
"nameEnglisch" -> if (dir == SortDirection.ASC) list.sortedBy { it.nameEnglisch?.lowercase() } else list.sortedByDescending { it.nameEnglisch?.lowercase() }
"isoAlpha2Code" -> if (dir == SortDirection.ASC) list.sortedBy { it.isoAlpha2Code.lowercase() } else list.sortedByDescending { it.isoAlpha2Code.lowercase() }
"isoAlpha3Code" -> if (dir == SortDirection.ASC) list.sortedBy { it.isoAlpha3Code.lowercase() } else list.sortedByDescending { it.isoAlpha3Code.lowercase() }
else -> list // unbekanntes Feld ignorieren
}
}
list
}
// Paging (unterstützt unpaged=true oder size=-1)
val total = sorted.size.toLong()
val isUnpaged = unpaged || size == -1
val pageContent: List<LandDefinition>
val effPage: Int
val effSize: Int
val totalPages: Int
val hasNext: Boolean
val hasPrevious: Boolean
if (isUnpaged) {
pageContent = sorted
effPage = 0
effSize = sorted.size
totalPages = 1
hasNext = false
hasPrevious = false
} else {
val fromIndex = (page * size).coerceAtLeast(0)
val toIndex = min(fromIndex + size, sorted.size)
pageContent = if (fromIndex in 0..sorted.size) sorted.subList(fromIndex, toIndex) else emptyList()
totalPages = if (size > 0) ((total + size - 1) / size).toInt() else 1
hasNext = page + 1 < totalPages
hasPrevious = page > 0
effPage = page
effSize = size
}
val dtoPage = pageContent.map { it.toDto() }
val paged = PagedResponse.create(
content = dtoPage,
page = effPage,
size = effSize,
totalElements = total,
totalPages = totalPages,
hasNext = hasNext,
hasPrevious = hasPrevious
)
call.respond(ApiResponse.success(paged))
}
get("/{id}") {
@ -70,13 +188,13 @@ class CountryController(
}
?: return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("INVALID_ID", "Missing or invalid ID")
ApiResponse.error<Unit>(ErrorCodes.INVALID_ID, "Missing or invalid ID")
)
val response = getCountryUseCase.getById(id)
response.country?.let {
call.respond(ApiResponse.success(it.toDto()))
} ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>("NOT_FOUND", "Country not found"))
} ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>(ErrorCodes.NOT_FOUND, "Country not found"))
}
post {
@ -101,7 +219,7 @@ class CountryController(
} else {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("CREATION_FAILED", response.errors.joinToString())
ApiResponse.error<Unit>(ErrorCodes.CREATION_FAILED, response.errors.joinToString())
)
}
}
@ -120,7 +238,7 @@ class CountryController(
istEwrMitglied = istEwrMitglied,
istAktiv = istAktiv,
sortierReihenfolge = sortierReihenfolge,
createdAt = createdAt.toString(),
updatedAt = updatedAt.toString()
createdAt = createdAt,
updatedAt = updatedAt
)
}

View File

@ -1,11 +1,11 @@
plugins {
kotlin("jvm")
alias(libs.plugins.kotlinJvm)
}
dependencies {
implementation(projects.backend.services.masterdata.masterdataDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(libs.kotlinx.datetime)
testImplementation(projects.platform.platformTesting)
testImplementation(projects.platform.platformTesting)
}

View File

@ -4,28 +4,27 @@ plugins {
}
kotlin {
jvm()
js(IR) {
browser()
}
jvm()
js(IR) {
browser()
}
sourceSets {
val commonMain by getting {
dependencies {
// KORREKTUR: Diese zwei Zeilen hinzufügen
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
val jvmTest by getting {
dependencies {
implementation(projects.platform.platformTesting)
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
val jvmTest by getting {
dependencies {
implementation(projects.platform.platformTesting)
}
}
}
}

View File

@ -1,16 +1,16 @@
plugins {
kotlin("jvm")
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.spring.boot) apply false
alias(libs.plugins.spring.dependencyManagement)
alias(libs.plugins.spring.dependencyManagement)
alias(libs.plugins.kotlinSpring)
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.platform.platformDependencies)
implementation(projects.backend.services.masterdata.masterdataDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.backend.infrastructure.cache.cacheApi)
implementation(projects.backend.infrastructure.eventStore.eventStoreApi)
implementation(projects.backend.infrastructure.messaging.messagingClient)
@ -21,7 +21,8 @@ dependencies {
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
implementation("org.postgresql:postgresql")
// Laufzeit-Treiber aus dem Version Catalog statt harter GAV
runtimeOnly(libs.postgresql.driver)
testImplementation(projects.platform.platformTesting)
testImplementation(projects.platform.platformTesting)
}

View File

@ -17,6 +17,7 @@ dependencies {
implementation(projects.backend.services.masterdata.masterdataDomain)
implementation(projects.backend.services.masterdata.masterdataInfrastructure)
implementation(projects.backend.services.masterdata.masterdataCommon)
// Benötigt, da der Service die API-Controller (Ktor) als Beans verdrahtet
implementation(projects.backend.services.masterdata.masterdataApi)
// Infrastruktur-Clients

View File

@ -13,4 +13,15 @@ object ErrorCodes {
val DATABASE_ERROR = ErrorCode("DATABASE_ERROR")
val TRANSACTION_ERROR = ErrorCode("TRANSACTION_ERROR")
val VALIDATION_ERROR = ErrorCode("VALIDATION_ERROR")
// Allgemeine API-/HTTP-nahe Fehlercodes zur Harmonisierung
val INVALID_ID = ErrorCode("INVALID_ID")
val NOT_FOUND = ErrorCode("NOT_FOUND")
val CREATION_FAILED = ErrorCode("CREATION_FAILED")
val UPDATE_FAILED = ErrorCode("UPDATE_FAILED")
val DELETE_FAILED = ErrorCode("DELETE_FAILED")
val CONFLICT = ErrorCode("CONFLICT")
val MISSING_PARAMETER = ErrorCode("MISSING_PARAMETER")
val INVALID_PARAMETER = ErrorCode("INVALID_PARAMETER")
val UNAUTHORIZED = ErrorCode("UNAUTHORIZED")
val FORBIDDEN = ErrorCode("FORBIDDEN")
}

View File

@ -0,0 +1,36 @@
package at.mocode.core.domain.model
import kotlinx.serialization.Serializable
/**
* Gemeinsame Query-Modelle für Filter, Sortierung und Pagination.
*/
@Serializable
enum class SortDirection { ASC, DESC }
@Serializable
data class SortParam(
val field: String,
val direction: SortDirection = SortDirection.ASC
)
@Serializable
data class PageRequest(
val page: PageNumber = PageNumber(0),
/**
* Wenn size == -1 oder unpaged == true, wird die Paginierung aufgehoben und alle Treffer geliefert.
*/
val size: Int = 20,
val sort: List<SortParam> = emptyList(),
val unpaged: Boolean = false
)
@Serializable
data class FilterParams(
val search: String? = null,
/**
* active: true = nur aktive (Default), false = nur inaktive, all = alle
*/
val active: String? = null
)