Architecture, Battles & Error Handling
We koppelen de battle-engine uit Week 03 aan de Ktor-backend van Week 04 en 05. Daarbij leren we hoe dependency injection, foutafhandeling, configuratie en asynchrone uitvoering samenwerken, zonder onnodige gamefeatures toe te voegen.
Wat bouwen we deze week?
De monsters blijven opgeslagen in de bestaande MonsterRepository. De bestaande BattleService haalt deelnemers op via start() en voert daarna de simulatie uit met fight(). De battle verandert uitsluitend tijdelijke HP-waarden, niet de database of het vaste Stats-object.
| Onderdeel | Verantwoordelijkheid | Herkomst |
|---|---|---|
Monster, Stats, Element | Gevalideerde basisgegevens | Week 02 |
BattleService, DamageCalculator | Deterministische battle-engine | Week 03 |
MonsterRepository | Opslagcontract met findById() | Week 04 |
MonsterService | CRUD, zoeken en sorteren | Week 04–05 |
BattleService.start() | IDs valideren en deelnemers laden | Week 06-uitbreiding |
BattleRoutes + DTO's | HTTP en JSON vertalen | Week 06 |
Codecontext: de kleine voorbeelden hieronder zijn leervoorbeelden of projectfragmenten. De toe te voegen Week 06-code staat gegroepeerd onder de geïntegreerde implementatie; de Week 02–05-bestanden blijven de bron van waarheid. De getoonde HTTP-resultaten zijn verwachte responses; de complete Amper/Ktor-app is hiermee niet automatisch uitgevoerd.
De 30 onderwerpen
We behandelen elk onderwerp aan de hand van de betekenis, de werking, een code- of configuratievoorbeeld en de ontwerpkeuze voor MonsterBattles. JWT en uploads zijn afzonderlijke leerconcepten, geen verplichte spelonderdelen.
01Separation of concerns
Separation of concerns betekent dat iedere module één duidelijk soort werk uitvoert. Een route handelt HTTP af; een service beheert use cases; een repository levert data.
Hoe werkt het? De route ontvangt de HTTP-aanvraag, de service laadt monsters, en de engine berekent het resultaat. Elke laag heeft eigen invoer en uitvoer.
// HTTP delegates the work instead of calculating damage.
post("/battles") {
val request = call.receive<StartBattleRequest>()
call.respond(battleService.start(request.attackerId, request.defenderId))
}POST /battles → battleService.start(...)Waarom en wanneer in MonsterBattles? De battleformule hoort niet in de HTTP-route, zodat de formule ook buiten Ktor testbaar is.
Wanneer kies je iets anders? Gebruik één bestand alleen voor een zeer klein oefenprogramma; voor uitbreidbare API’s zijn gescheiden verantwoordelijkheden beter.
02Application layers
De route vertaalt HTTP naar een service-aanroep. De service organiseert bedrijfsregels en roept repositories aan. Het domein bevat monstergegevens en de battlelogica.
Hoe werkt het? Een verzoek stroomt van HTTP naar applicatielogica en vervolgens naar het repositorycontract. Het domein hoeft geen Ktor- of databaseklassen te kennen.
// Route -> service -> repository -> domain
class MonsterService(private val repository: MonsterRepository) {
fun get(id: Int): Monster? = repository.findById(id)
}GET /monsters/1 → service → repository → PyronWaarom en wanneer in MonsterBattles? Deze lagen voorkomen dat database- of HTTP-details overal in MonsterBattles voorkomen.
Wanneer kies je iets anders? Voor een triviale consoleapp zijn minder lagen voldoende; voeg de extra lagen toe wanneer HTTP en opslag gescheiden moeten kunnen veranderen.
03Feature-based architecture
Bij featuregeoriënteerde indeling staan bestanden bij hun functionaliteit, bijvoorbeeld monsters en battles. Een alternatief is technisch groeperen op routes/services/repositories.
Hoe werkt het? Groepeer bestanden onder features/monsters en features/battles. Hergebruik het monsterdomein vanuit battles; kopieer geen modellen.
nl/monsterbattles/api/
├── Application.kt
├── common/ApiError.kt
├── config/Plugins.kt
└── features/
├── monsters/
│ ├── model/Monster.kt, Stats.kt, Element.kt
│ ├── repository/MonsterRepository.kt
│ ├── MonsterService.kt
│ └── MonsterRoutes.kt
└── battles/
├── model/BattleResult.kt, BattleEvent.kt
├── dto/BattleDtos.kt
├── DamageCalculator.kt
├── BattleService.kt
└── BattleRoutes.ktDe battle-feature gebruikt het bestaande monsterdomein, zonder eigen kopie.Waarom en wanneer in MonsterBattles? Featuremappen helpen als battles en monsterbeheer afzonderlijk groeien.
Wanneer kies je iets anders? Een indeling op techniek werkt voor kleine toepassingen; kies features wanneer je per functionaliteit wilt navigeren.
04Dependency injection
Bij constructor injection ontvangt een class haar afhankelijkheden via de constructor. De class hoeft daardoor niet zelf een concrete repository aan te maken.
Hoe werkt het? Geef een MonsterRepository en de bestaande BattleService aan de constructor van de service. Daardoor kun je per test een fake meegeven.
// In Week 06 the existing BattleService is extended with repository injection.
class BattleService(private val repository: MonsterRepository) {
fun start(attackerId: Int, defenderId: Int): BattleResult {
require(attackerId > 0 && defenderId > 0 && attackerId != defenderId)
val attacker = repository.findById(attackerId) ?: throw MonsterNotFoundException(attackerId)
val defender = repository.findById(defenderId) ?: throw MonsterNotFoundException(defenderId)
return fight(attacker, defender) // Week 03 simulation algorithm, preserved.
}
// The existing fight(attacker, defender) implementation remains in BattleService.
}BattleService ontvangt repository via constructor.Waarom en wanneer in MonsterBattles? BattleService werkt zowel met een echte repository als met een fake voor tests.
Wanneer kies je iets anders? Handmatig constructor-injecteren is hier voldoende; een DI-framework is pas nuttig bij veel afhankelijke objecten.
05Dependency inversion
Het Dependency Inversion Principle laat de servicelaag afhangen van een abstract contract. Een concrete in-memory of database-implementatie vult dat contract in.
Hoe werkt het? De use case vraagt om MonsterRepository, niet om InMemoryMonsterRepository. In Week 07 kan dezelfde interface door Exposed worden geïmplementeerd.
// Dependency Inversion: unchanged interface introduced in Week 04.
interface MonsterRepository {
fun findAll(): List<Monster>
fun findById(id: Int): Monster?
fun create(name: String, element: Element, stats: Stats): Monster
fun update(id: Int, name: String, element: Element, stats: Stats): Monster?
fun delete(id: Int): Boolean
}
// In Week 07, ExposedMonsterRepository replaces InMemoryMonsterRepository.Service kent MonsterRepository, niet de concrete opslag.Waarom en wanneer in MonsterBattles? Bij de overstap naar H2 hoeft BattleService niet herschreven te worden.
Wanneer kies je iets anders? Een concrete repository mag in een snelle demo; voor tests en databasewissels is een interface stabieler.
06Battle use case
De battle use case haalt twee verschillende monsters op, bepaalt de startvolgorde op basis van Speed, wisselt aanvallen af en beëindigt bij HP 0. De damage is maxOf(1, attack - defense). Bij gelijke Speed begint het eerst gekozen monster.
Hoe werkt het? Controleer eerst IDs, laad beide monsters en roep fight() aan. De engine sorteert de aanvallers op speed en houdt HP alleen tijdens deze battle bij.
val result = BattleService(repository).fight(pyron, aquara)
println("Winner: ${result.winnerId}")
println("Rounds: ${result.rounds} | Turns: ${result.turns}")
// pyron and aquara are the immutable base monsters from Week 02.Winner: 1
Rounds: 7 | Turns: 13Waarom en wanneer in MonsterBattles? De regels blijven op één plek, onafhankelijk van REST of database.
Wanneer kies je iets anders? Zonder HTTP kun je rechtstreeks BattleService.fight() gebruiken; de service is nodig bij IDs uit de API.
07StartBattleRequest
Een request DTO beschrijft welke JSON-velden de client naar de server stuurt. De client hoeft geen volledige Monster-objecten mee te geven.
Hoe werkt het? Ontvang uitsluitend attackerId en defenderId in de JSON-body. De server haalt naam, element en stats zelf op uit de repository.
@Serializable
data class StartBattleRequest(val attackerId: Int, val defenderId: Int)
// Request only contains IDs: HP and damage are server-owned rules.{"attackerId":1,"defenderId":2}Waarom en wanneer in MonsterBattles? Met twee monster-ID’s haalt de backend zelf de echte stats op.
Wanneer kies je iets anders? Gebruik een uitgebreid request alleen als de client die informatie werkelijk hoort te beheren; monsterstats blijven server-owned.
08BattleResponse
Een response DTO bepaalt welke battlegegevens de client ontvangt. Naast winnaar en resterende HP bevat de response een log van alle acties.
Hoe werkt het? Zet BattleResult en BattleEvent uit het domein om naar serialiseerbare DTO’s. De client krijgt 7 rondes, 13 aanvallen en een log met 13 events.
@Serializable
data class BattleEventResponse(
val turn: Int,
val attackerId: Int,
val defenderId: Int,
val damage: Int,
val defenderRemainingHp: Int
)
@Serializable
data class BattleResponse(
val winnerId: Int,
val rounds: Int,
val turns: Int,
val attackerRemainingHp: Int,
val defenderRemainingHp: Int,
val log: List<BattleEventResponse>
)BattleResponse: winnerId=1, rounds=7, turns=13, log.size=13 (verwacht)Waarom en wanneer in MonsterBattles? Een stabiel responsecontract voorkomt dat de interne domeinclasses direct uitlekken via JSON.
Wanneer kies je iets anders? Gebruik een domeinobject direct als response alleen wanneer het publieke API-contract en interne model bewust gelijk mogen blijven.
09BattleRoutes
Ktor gebruikt routing { post(...) { ... } } om een HTTP POST aan de battle-service te koppelen. De route ontvangt de DTO, roept de service aan en stuurt JSON terug.
Hoe werkt het? De route deserializeert StartBattleRequest, roept de service aan en respondeert met HTTP 200 plus BattleResponse.
// Fragment inside fun Route.battleRoutes(useCase: BattleService)
post("/battles") {
val request = call.receive<StartBattleRequest>()
val result = useCase.start(request.attackerId, request.defenderId)
call.respond(HttpStatusCode.OK, result.toResponse())
}200 OK + BattleResponse (rounds=7, turns=13, 13 events)Waarom en wanneer in MonsterBattles? POST /battles blijft beperkt tot requestverwerking; het rekenwerk staat in de service.
Wanneer kies je iets anders? Een route mag minimale HTTP-validatie doen; de eigenlijke businessregels horen in de service.
10Validatie
Validatie beschermt de service tegen ongeldige inputs, zoals niet-bestaande monster-ID’s, dezelfde ID twee keer of negatieve stats. Controleer ID’s voordat de battle begint.
Hoe werkt het? Controleer positieve IDs en een verschillend deelnemerspaar vóór repositorytoegang. Ongeldige invoer wordt een HTTP 400.
fun validateIds(attackerId: Int, defenderId: Int) {
require(attackerId > 0 && defenderId > 0) { "IDs must be positive" }
require(attackerId != defenderId) { "Choose two different monsters" }
}
validateIds(1, 2)Geldige IDs leveren geen fout op; ongeldige IDs geven 400.Waarom en wanneer in MonsterBattles? Voorkom dat een battle onduidelijk faalt halverwege een beurt.
Wanneer kies je iets anders? Voor complexe validatie kun je resultaattypes gebruiken; eenvoudige precondities zijn overzichtelijk met require.
11Exceptions
Een exception geeft aan dat een bewerking niet normaal kan doorgaan. Een domeinspecifieke exception kan een niet-bestaand monster duidelijk signaleren.
Hoe werkt het? Gooi een specifieke exception wanneer een monster niet bestaat. Vertaal de exception op één plek naar een foutrespons.
class MonsterNotFoundException(id: Int) :
RuntimeException("Monster $id was not found")
fun requireMonster(monster: Monster?, id: Int): Monster =
monster ?: throw MonsterNotFoundException(id)Missing monster → MonsterNotFoundExceptionWaarom en wanneer in MonsterBattles? StatusPages kan de exception centraal omzetten naar een HTTP 404-response.
Wanneer kies je iets anders? Gebruik sealed result-types als je fouten expliciet als data wilt teruggeven; deze les gebruikt uitzonderingen en StatusPages.
12require, check, error
require controleert aannames over functieargumenten en werpt IllegalArgumentException. check bewaakt interne toestand en werpt IllegalStateException; error werpt ook IllegalStateException.
Hoe werkt het? Gebruik require voor argumenten, check voor aannames over objecttoestand en error wanneer een onmogelijke tak toch wordt uitgevoerd.
require(attack >= 0) { "Attack cannot be negative" }
check(currentHp >= 0) { "HP cannot be negative" }
if (turns > 1000) error("Battle exceeded turn limit")Bij geldige waarden: geen exception.Waarom en wanneer in MonsterBattles? Gebruik require voor ongeldige damage-input en check wanneer een interne battletoestand onmogelijk is.
Wanneer kies je iets anders? Gebruik require voor door de client aangeleverde argumenten en check voor interne invariantcontrole.
13StatusPages
De Ktor-plugin StatusPages vangt specifieke exceptions af en maakt consistente JSON-foutresponses. Door foutafhandeling te centraliseren blijven routes overzichtelijk.
Hoe werkt het? Installeer StatusPages eenmaal in de Ktor-configuratie. Een handler zet exceptions om in een ApiError met statuscode.
install(StatusPages) {
exception<MonsterNotFoundException> { call, cause ->
call.respond(HttpStatusCode.NotFound, ErrorResponse(cause.message ?: "Not found"))
}
exception<IllegalArgumentException> { call, cause ->
call.respond(HttpStatusCode.BadRequest, ErrorResponse(cause.message ?: "Bad request"))
}
}404 {"message":"Monster 99 was not found"}Waarom en wanneer in MonsterBattles? Niet-gevonden monsters geven 404 terug; ongeldige IDs 400.
Wanneer kies je iets anders? Zonder centrale foutafhandeling ontstaat dubbele try/catch-code in routes; gebruik die alleen voor plaatselijk herstel.
14Logging
Logging legt gebeurtenissen vast zonder de spelregels te beïnvloeden. Ktor heeft toegang tot application.log. Log requests en foutcontext, maar geen wachtwoorden of tokens.
Hoe werkt het? Log relevante informatie bij het binnenkomen, slagen of falen van een aanvraag; gebruik geen println als serverlogging.
application.log.info("Starting battle for monster IDs {} and {}", attackerId, defenderId)
application.log.warn("Unknown monster ID: {}", missingId)INFO Starting battle for monster IDs 1 and 2Waarom en wanneer in MonsterBattles? Je ziet in de logs welke battle startte en of validatie mislukte.
Wanneer kies je iets anders? Gebruik gestructureerde logging in plaats van losse console-uitvoer zodra je een server draait.
15Configuratie met application.yaml
application.yaml kan poort, modules en andere niet-geheime instellingen bevatten. Geheimen horen in omgevingsvariabelen of een secret manager, niet hardcoded in Git.
Hoe werkt het? Lees host en poort uit application.yaml. Maak geen secrets of omgevingsspecifieke waarden hardcoded in Kotlin.
ktor:
application:
modules:
- nl.monsterbattles.api.ApplicationKt.module
deployment:
port: 8080Server luistert op poort 8080.Waarom en wanneer in MonsterBattles? De serverpoort is configureerbaar zonder Kotlin-code te wijzigen.
Wanneer kies je iets anders? Gebruik omgevingsvariabelen voor geheimen en deploymentinstellingen; application.yaml is geschikt voor niet-gevoelige defaults.
16Testbaarheid met fake repositories
Een fake repository is een simpele implementatie van een echt interfacecontract voor tests. Er is geen database nodig om de battlelogica te testen.
Hoe werkt het? Maak een fake MonsterRepository dat gecontroleerde monsters teruggeeft. Test de service onafhankelijk van echte opslag of HTTP.
// Test with the same repository interface; provide a fake implementation.
val repository: MonsterRepository = FakeMonsterRepository()
val service = BattleService(repository)
val result = service.start(1, 2)
check(result.winnerId == 1 && result.rounds == 7 && result.turns == 13)BattleService gebruikt testdata zonder database.Waarom en wanneer in MonsterBattles? De service krijgt een fake repository; de bestaande BattleService krijgt twee domeinmonsters.
Wanneer kies je iets anders? Ktor-integratietests zijn beter voor HTTP-contracten; unit tests met fakes zijn sneller voor afzonderlijke regels.
17Sequence diagrams
Een sequence diagram laat in tijdsvolgorde zien welke component welke methode aanroept. Het verduidelijkt de samenwerking tussen client, route, service en repository.
Hoe werkt het? Lees de pijlen van links naar rechts en van boven naar beneden: client, route, service, repository en damagecalculator; fouten lopen via StatusPages.
Client -> BattleRoutes : POST /battles
BattleRoutes -> BattleService : start(1, 2)
BattleService -> MonsterRepository : findById(1), findById(2)
MonsterRepository --> BattleService : Pyron, Aquara
BattleService -> DamageCalculator : calculate(...)
DamageCalculator --> BattleService : damage
BattleService --> BattleRoutes : BattleResult
BattleRoutes --> Client : 200 OK + BattleResponse
alt invalid input: StatusPages => 400
alt unknown monster: StatusPages => 404Client ontvangt winnaar en battlelog.Waarom en wanneer in MonsterBattles? Je kunt exact aanwijzen waar de requestvalidatie en de battleberekening plaatsvinden.
Wanneer kies je iets anders? Gebruik een activitydiagram voor algoritmische beslissingen; een sequence diagram toont juist interactie tussen objecten.
BattleService verzorgt zowel de repository-opvraag als de simulatie.18suspend en coroutines
Een suspend function kan uitvoering pauzeren zonder een thread te blokkeren, mits de aangeroepen API daadwerkelijk suspending/non-blocking is. Ktor-routes draaien in een coroutine-context.
Hoe werkt het? Een suspend-functie kan pauzeren zonder een serverthread vast te houden mits de onderliggende operatie suspensie ondersteunt.
suspend fun loadMonster(id: Int): Monster? = repository.findById(id)
get("/monsters/{id}") {
val id = call.parameters["id"]?.toIntOrNull() ?: return@get call.respond(HttpStatusCode.BadRequest)
call.respond(service.loadMonster(id) ?: return@get call.respond(HttpStatusCode.NotFound))
}GET /monsters/1 → 200 OK + Monster JSONWaarom en wanneer in MonsterBattles? Database- en netwerk-I/O kunnen later worden uitgevoerd zonder onnodig threads vast te houden.
Wanneer kies je iets anders? Gebruik gewone functies voor volledig synchrone berekeningen zoals de huidige damagecalculator.
19Blocking versus non-blocking
Blocking I/O houdt een thread bezet tijdens het wachten; non-blocking I/O laat die thread nuttig ander werk doen. Alleen suspend toevoegen maakt een blocking databasecall niet automatisch non-blocking.
Hoe werkt het? Een blokkerende JDBC-aanroep blijft blokkerend wanneer je hem in een suspend-functie zet. Gebruik een geschikte dispatcher of frameworkbenadering.
// Blocking calls need an appropriate dispatcher if used from suspend code.
suspend fun readLegacyData(): String = withContext(Dispatchers.IO) {
blockingFileRead()
}Coroutine kan wachten zonder de Ktor-eventloop te blokkeren.Waarom en wanneer in MonsterBattles? Voor een drukke API moet de repository passende non-blocking of dispatcher-gebaseerde I/O gebruiken.
Wanneer kies je iets anders? Gebruik niet-blokkerende drivers waar mogelijk; beheers blokkerende databasecalls apart.
20Authentication
Authentication controleert wie een request doet. Dat kan met een wachtwoord, sessie of token, afhankelijk van het systeem. Dit is een aanvullend lesvoorbeeld, geen vereiste voor de baseline game.
Hoe werkt het? Authentication identificeert de client. De basisgame heeft geen accounts; dit onderwerp staat als zelfstandig Ktor-voorbeeld.
install(Authentication) {
bearer("auth-bearer") {
authenticate { credential ->
if (isValidDemoToken(credential.token)) UserIdPrincipal("demo") else null
}
}
}Zonder geldig token → 401 UnauthorizedWaarom en wanneer in MonsterBattles? Bij een hypothetische beheerroute kan de API vaststellen wie monsters mag wijzigen.
Wanneer kies je iets anders? Voeg authentication pas toe als MonsterBattles accounts of afgeschermde gegevens krijgt.
21Authorization
Authorization controleert wat een geauthenticeerde gebruiker mag doen. Inloggen of een geldig token hebben geeft niet automatisch toegang tot iedere handeling.
Hoe werkt het? Authorization toetst of een geauthenticeerde client een actie mag uitvoeren. Dat staat los van de vraag wie de client is.
val user = call.principal<UserIdPrincipal>()
if (user?.name != "admin") {
return@get call.respond(HttpStatusCode.Forbidden)
}
// Continue with admin-only operation.Geauthenticeerde gebruiker zonder rechten → 403 ForbiddenWaarom en wanneer in MonsterBattles? Een beheerder zou monsters mogen verwijderen, een gewone gebruiker niet.
Wanneer kies je iets anders? Kies een eenvoudige allow/deny-regel voor een klein systeem, uitgebreid rechtenbeheer pas bij meerdere rollen.
22JWT
JWT is een gesigneerd token met claims, doorgaans in drie Base64URL-delen: header.payload.signature. Validatie controleert onder meer de handtekening, vervaldatum en verwachte issuer/audience. Encodeerbaarheid betekent geen geheimhouding.
Hoe werkt het? JWT is een ondertekend token met claims. De server verifieert handtekening, vervaldatum en issuer voordat claims worden vertrouwd.
install(Authentication) {
jwt("auth-jwt") {
realm = "monsterbattles"
verifier(jwtVerifier) // Validates signature and expected claims.
validate { credential ->
credential.payload.subject?.let { JWTPrincipal(credential.payload) }
}
}
}Geldig JWT → request gaat door; ongeldig JWT → 401Waarom en wanneer in MonsterBattles? De beschermde demo-endpoint gebruikt een gecontroleerd JWT, terwijl /battles in de basis zonder account blijft werken.
Wanneer kies je iets anders? Gebruik server-side sessies in een browsergericht systeem wanneer tokenbeheer niet nodig is.
23Bearer authentication
Een bearer-token wordt door de client verstuurd in de Authorization-header. Wie het token bezit, kan het gebruiken zolang het geldig is; gebruik daarom HTTPS en korte geldigheidsduur.
Hoe werkt het? Bij Bearer-authenticatie zet de client een token in de Authorization-header. Ktor valideert dat token via een auth-provider.
GET /admin/monsters HTTP/1.1
Host: localhost:8080
Authorization: Bearer eyJhbGciOi...200 OK bij geldig token, anders 401 UnauthorizedWaarom en wanneer in MonsterBattles? Een beheerclient stuurt het token mee bij een beschermde monsterbeheerroute.
Wanneer kies je iets anders? Een cookie-gebaseerde sessie is een alternatief voor browsers; Bearer past bij veel API-clients.
24Protected routes
In Ktor groepeer je beveiligde endpoints met authenticate("auth-jwt") { ... }. Publieke routes blijven buiten deze configuratie.
Hoe werkt het? Bescherm routes met een authenticate-blok. De monster- en battle-endpoints uit het basisproject blijven zonder login toegankelijk.
routing {
get("/monsters") { call.respond(monsterService.list()) }
authenticate("auth-jwt") {
delete("/admin/monsters/{id}") { /* Authorization + delete */ }
}
}GET /monsters publiek; DELETE /admin/... token vereist.Waarom en wanneer in MonsterBattles? Adminfunctionaliteit kan beveiligd worden zonder de publieke battle-API te veranderen.
Wanneer kies je iets anders? Een publiek endpoint blijft buiten authenticate; bescherm alleen routes met echte toegangsvereisten.
25Rollen en permissies
Een role groepeert rechten, een permission staat voor een specifieke actie. De backend moet rollen/permissions zelf afdwingen; een JWT-claim alleen is geen autorisatiebeleid.
Hoe werkt het? Laat permissies controleren in een autorisatielaag in plaats van toegangsregels te kopiëren in iedere route.
enum class Permission { READ_MONSTER, DELETE_MONSTER }
fun permissions(role: String): Set<Permission> = when (role) {
"ADMIN" -> Permission.entries.toSet()
else -> setOf(Permission.READ_MONSTER)
}
println(Permission.DELETE_MONSTER in permissions("VIEWER"))falseWaarom en wanneer in MonsterBattles? Een adminrol kan deleteMonster krijgen terwijl een viewer alleen mag lezen.
Wanneer kies je iets anders? Gebruik eenvoudige rechtenchecks zolang er nauwelijks rollen zijn; voeg role-based access control toe wanneer nodig.
26File uploads
Multipart/form-data verstuurt bestanden samen met formuliervelden. Een server moet bestandsgrootte, type en opslagpad valideren en uploads verwerken zonder onbegrensd geheugenverbruik.
Hoe werkt het? Verwerk multipart-data in een route, begrens de bestandsgrootte en accepteer geen willekeurige bestandsnamen of paden.
post("/admin/uploads") {
val multipart = call.receiveMultipart()
multipart.forEachPart { part ->
when (part) {
is PartData.FileItem -> {
// Validate size/type; stream to a safe server-generated path.
}
else -> Unit
}
part.dispose()
}
call.respond(HttpStatusCode.Created)
}POST multipart upload → 201 CreatedWaarom en wanneer in MonsterBattles? Een optionele beheerfunctie kan monsterafbeeldingen ontvangen; dit is geen onderdeel van de basisbattle.
Wanneer kies je iets anders? Als de game geen uploads nodig heeft, implementeer geen uploadroute; dit blijft los lesmateriaal.
27Image uploads
Een afbeeldingupload vereist controles op bestandsgrootte, toegestane bestandstypen en inhoud. Vertrouw niet alleen op de bestandsnaam of Content-Type-header. Sla op onder een servergegenereerde naam buiten uitvoerbare codepaden.
Hoe werkt het? Controleer werkelijk bestandsformaat, maximale grootte en veilige opslaglocatie; alleen een extensie controleren is onvoldoende.
// Conceptual validation, after a bounded read/stream.
require(uploadedBytes.size <= MAX_IMAGE_BYTES)
require(detectImageFormat(uploadedBytes) in setOf("png", "jpeg"))
val safeName = "${java.util.UUID.randomUUID()}.png"
// Re-encode verified image; persist in configured uploads directory.Ongeldige afbeelding → 400; geldige afbeelding → opgeslagen.Waarom en wanneer in MonsterBattles? Monsterafbeeldingen kunnen optioneel opgeslagen en later als statische bestanden beschikbaar gemaakt worden.
Wanneer kies je iets anders? Voor een avatarfunctie kan een externe mediaservice beter passen dan eigen opslag.
28Static content
Ktor kan bestaande bestanden, zoals afbeeldingen, beschikbaar maken met staticResources of staticFiles. Publiceer uitsluitend bestanden die publiek bereikbaar mogen zijn.
Hoe werkt het? Serveer publieke vaste assets via een statische route; houd privébestanden buiten die route.
routing {
staticResources("/assets", "public")
// Resources from src/main/resources/public are served under /assets.
}GET /assets/pyron.png → 200 OK (als resource bestaat).Waarom en wanneer in MonsterBattles? Een client kan een geüploade monsterafbeelding ophalen via een URL.
Wanneer kies je iets anders? Gebruik een webserver of CDN voor veel of grote assets; Ktor-static werkt voor beperkte publieke bestanden.
29API-error tests
HTTP-tests controleren niet alleen succes maar ook ontbrekende en ongeldige IDs. Ktor testApplication maakt het mogelijk om requests te versturen zonder echte netwerkpoort.
Hoe werkt het? Gebruik testApplication voor status, Content-Type en ApiError-code. Test zowel ongeldige IDs als onbekende monsters.
@Test
fun `unknown monster returns 404`() = testApplication {
application { module() }
val response = client.post("/battles") {
contentType(ContentType.Application.Json)
setBody("""{"attackerId":1,"defenderId":999}""")
}
assertEquals(HttpStatusCode.NotFound, response.status)
}Test passed: 404 Not FoundWaarom en wanneer in MonsterBattles? Voor POST /battles verwachten we 400 bij dezelfde ID en 404 bij een onbekend monster.
Wanneer kies je iets anders? Unit tests testen inhoudelijke regels zonder HTTP; API-tests controleren het volledige contract.
30Architectuurcontrole
De laatste controle volgt één request van client naar route, service, repository en domeinberekening terug naar een response. Valideer op de grens, houd foutvertaling centraal en maak afhankelijkheden expliciet.
Hoe werkt het? Controleer afhankelijkheden: routes kennen services, services kennen repositorycontracten en domeinregels kennen geen Ktor.
Client -> BattleRoutes -> BattleService.start(ids)
BattleService -> MonsterRepository.findById(ids)
BattleService -> BattleService.fight(monsters) [Week 03 algorithm]
BattleService -> DamageCalculator.calculate(...)
BattleService -> BattleResult -> BattleResponse -> Client200: complete battle; 400: invalid request; 404: missing monster.Waarom en wanneer in MonsterBattles? Alle battle-regels blijven herbruikbaar; de DTO bepaalt de JSON-structuur, de repository de opslag.
Wanneer kies je iets anders? Gebruik een dependencydiagram voor grote systemen; voor deze app volstaan packages en een expliciete bestandsstructuur.
Geïntegreerd voorbeeld — POST /battles
Dit is de doorlopende projectimplementatie. Week 03 bevat de zuivere simulatie BattleService.fight(attacker, defender). In Week 06 voegen we aan diezelfde class de repositoryafhankelijkheid en de methode start(attackerId, defenderId) toe. Er komt geen nieuwe serviceclass naast de bestaande BattleService.
package nl.monsterbattles.api.features.battles
import nl.monsterbattles.api.features.monsters.repository.MonsterRepository
import nl.monsterbattles.api.features.monsters.MonsterNotFoundException
import nl.monsterbattles.api.features.battles.model.BattleResult
// Voeg de constructor en start() toe aan de bestaande BattleService uit Week 03.
// Behoud de bestaande fight(attacker: Monster, defender: Monster) volledig.
class BattleService(private val repository: MonsterRepository) {
fun start(attackerId: Int, defenderId: Int): BattleResult {
require(attackerId > 0 && defenderId > 0) { "Monster IDs must be positive" }
require(attackerId != defenderId) { "Choose two different monsters" }
val attacker = repository.findById(attackerId)
?: throw MonsterNotFoundException(attackerId)
val defender = repository.findById(defenderId)
?: throw MonsterNotFoundException(defenderId)
return fight(attacker, defender)
}
// Plaats hier ONGEWIJZIGD de fight()-methode uit Week 03.
// Dit is een contextafhankelijk samenvoegfragment, geen tweede BattleService-bestand.
}De code hierboven is bewust een samenvoegfragment: plak niet een tweede class naast de bestaande class. De daadwerkelijke fight()-methode staat in Week 03.
package nl.monsterbattles.api.features.battles.dto
import kotlinx.serialization.Serializable
import nl.monsterbattles.api.features.battles.model.BattleEvent
import nl.monsterbattles.api.features.battles.model.BattleResult
@Serializable
data class StartBattleRequest(val attackerId: Int, val defenderId: Int)
@Serializable
data class BattleEventResponse(
val turn: Int, val attackerId: Int, val defenderId: Int,
val damage: Int, val defenderRemainingHp: Int
)
@Serializable
data class BattleResponse(
val winnerId: Int, val rounds: Int, val turns: Int,
val attackerRemainingHp: Int, val defenderRemainingHp: Int,
val log: List<BattleEventResponse>
)
fun BattleEvent.toResponse() = BattleEventResponse(
turn, attackerId, defenderId, damage, defenderRemainingHp
)
fun BattleResult.toResponse() = BattleResponse(
winnerId, rounds, turns, attackerRemainingHp, defenderRemainingHp,
log.map { it.toResponse() }
)package nl.monsterbattles.api.features.battles
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import nl.monsterbattles.api.features.battles.dto.StartBattleRequest
import nl.monsterbattles.api.features.battles.dto.toResponse
fun Route.battleRoutes(service: BattleService) {
post("/battles") {
val request = call.receive<StartBattleRequest>()
call.respond(HttpStatusCode.OK,
service.start(request.attackerId, request.defenderId).toResponse())
}
}// Inside Application.module(), reuse the existing repository and MonsterService.
val battleService = BattleService(repository)
routing {
monsterRoutes(monsterService)
battleRoutes(battleService)
}
// Configure ContentNegotiation and StatusPages exactly once in configurePlugins().POST http://localhost:8080/battles
Content-Type: application/json
{"attackerId":1,"defenderId":2}200 OK: winnerId=1, rounds=7, turns=13, attackerRemainingHp=10, defenderRemainingHp=0; 13 log-events (verwacht)De basisbackend gebruikt geen JWT, rollen of uploads. Die onderwerpen blijven uitsluitend zelfstandige leervoorbeelden. Bij ongeldige IDs verwacht het contract 400; bij onbekende monsters 404, met het bestaande ApiError-formaat uit Week 04.
Cheat Sheet
| Concept | MonsterBattles-afspraak |
|---|---|
| Route / Use case / Repository | HTTP / ID-validatie en monsters ophalen / opslag |
| BattleService | Engine uit Week 03: fight(attacker, defender) |
| Dependency injection | Constructor ontvangt MonsterRepository-interface |
| BattleRequest | attackerId, defenderId |
| BattleResponse | winnerId, rounds, turns, resterende HP, log |
| Speed | Hoogste eerst; bij gelijkspel eerst gekozen monster |
| Damage | maxOf(1, attack - defense) |
| HP | Tijdelijk, minimum 0; geen persistent HP-verlies |
StatusPages | Exceptions naar JSON ApiError |
require / check | Ongeldige argumenten / ongeldige interne toestand |
application.yaml | Serverconfiguratie; geen secrets in code |
suspend | Ondersteunt suspensie; maakt JDBC niet vanzelf non-blocking |
| JWT / Bearer / rollen | Zelfstandige Ktor-lesstof, niet in de basisgame |
| Uploads / static content | Zelfstandige Ktor-lesstof, niet in de basisgame |
| Amper | module.yaml, ./kotlin run, ./kotlin test |