Ktor & REST API
We zetten de domeinmodellen van Week 02 om in een bereikbare Kotlin/Ktor-backend. HTTP-routes, JSON, DTO’s, de servicelaag en een in-memory repository vormen samen de eerste volledige MonsterBattles CRUD API.
Wat bouwen we deze week?
De bestaande Monster-class met Stats en Element blijft intact. We bouwen een dunne HTTP-adapter om die modellen heen, met een repositorycontract dat Week 07 ongewijzigd kan hergebruiken.
| Methode | Endpoint | Succes | Doel |
|---|---|---|---|
| GET | /monsters | 200 | Alle monsters |
| GET | /monsters/{id} | 200 / 404 | Monster op ID |
| POST | /monsters | 201 | Nieuw monster |
| PUT | /monsters/{id} | 200 / 404 | Volledig bijwerken |
| DELETE | /monsters/{id} | 204 / 404 | Verwijderen |
Afbakening: we gebruiken JetBrains Amper (module.yaml), geen Gradle. Filteren en sorteren worden in Week 05 toegevoegd; POST /battles volgt in Week 06; H2/Exposed pas in Week 07. Een codefragment binnen een onderwerp is een leervoorbeeld, niet automatisch een zelfstandig uitvoerbaar Kotlin-bestand.
Alle 33 onderwerpen
Per onderwerp vind je een definitie, hoe het werkt, code of HTTP, verwachte uitvoer, een ontwerpafweging en de concrete MonsterBattles-toepassing. De geïntegreerde projectbestanden staan na de onderwerpen.
01Backend fundamentals
Wat is het? Een backend verwerkt requests, voert bedrijfsregels uit en levert gegevens aan clients. Een browser, mobiele app of HTTP-client hoeft de spelregels niet zelf te implementeren.
Hoe werkt het? Een client verstuurt een request. Ktor ontvangt dat via een route, de service voert de use case uit en een repository levert opgeslagen gegevens. Daarna maakt de route een HTTP-response.
Client → HTTP request → Ktor Server → Service → Repository
Client ← HTTP response ← Ktor Server ← Service ← RepositoryEen client ontvangt dezelfde monstergegevens ongeacht het apparaat.Waarom deze aanpak? MonsterBattles houdt monsterbeheer en battlelogica centraal op de server.
Wanneer kies je iets anders? Een consoleprogramma kan zonder HTTP, zoals in Week 01–03.
Toepassing in MonsterBattles: We houden app-logica op de server, zodat iedere client dezelfde regels gebruikt.
02HTTP
Wat is het? HTTP is een request-responseprotocol. De client stuurt een request; de server reageert met een statuscode, headers en eventueel een body.
Hoe werkt het? HTTP koppelt een methode en URL aan een verzoek. De response heeft een status, headers en eventueel een body. JSON is een veelgebruikt uitwisselformaat, niet HTTP zelf.
GET http://localhost:8080/monsters/1
Accept: application/jsonHTTP/1.1 200 OK
Content-Type: application/json
{"id":1,"name":"Pyron","element":"FIRE"}Waarom deze aanpak? MonsterBattles gebruikt HTTP zodat een browser of testclient monsters kan ophalen.
Wanneer kies je iets anders? Voor intern lokaal functiegebruik is een HTTP-verbinding overbodig.
Toepassing in MonsterBattles: De API is voor browser, mobiele app en testclient bereikbaar.
03HTTP-structuur
Wat is het? Een request bestaat uit een methode, doeladres, headers en optioneel een body. Een response bestaat uit een statusregel, headers en optioneel een responsebody.
Hoe werkt het? Lees eerst de requestregel, dan headers zoals Content-Type, daarna een eventuele body. De response geeft met de statuscode aan wat er is gebeurd.
POST http://localhost:8080/monsters
Content-Type: application/json
Accept: application/json
{"name":"Pyron","element":"FIRE","hp":100,"attack":30,"defense":10,"speed":110}HTTP/1.1 201 Created
Content-Type: application/json
Location: /monsters/1Waarom deze aanpak? Begrijp waar je JSON, Content-Type en de juiste statuscode ziet.
Wanneer kies je iets anders? Voor payloads met alleen eenvoudige parameters is een body soms niet nodig.
Toepassing in MonsterBattles: Je kunt fouten in requests en responses gericht onderzoeken.
04REST
Wat is het? REST is een architectuurstijl waarbij resources via herkenbare URL’s en gestandaardiseerde HTTP-methoden worden benaderd. Requests horen hun benodigde context mee te sturen (stateless).
Hoe werkt het? Vertegenwoordigt een resource door een URL; gebruik GET/POST/PUT/DELETE voor standaardoperaties. Een request moet genoeg context bevatten om zelfstandig verwerkt te worden.
GET http://localhost:8080/monsters
###
GET http://localhost:8080/monsters/1200 OK — lijst met monsters
200 OK — monster met id 1Waarom deze aanpak? De URL /monsters beschrijft de resource; de HTTP-methode beschrijft de actie.
Wanneer kies je iets anders? Gebruik niet automatisch REST voor iedere willekeurige procedure; kies een helder contract.
Toepassing in MonsterBattles: Het contract blijft voorspelbaar en technologieonafhankelijk.
05Resources
Wat is het? Een resource is een identificeerbaar onderdeel van de API. Gebruik zelfstandige naamwoorden in URL’s, niet werkwoorden als /getMonster.
Hoe werkt het? Gebruik /monsters voor de collectie en /monsters/1 voor één item. Een battle is een uitvoerbare use case en heeft POST /battles.
/monsters → monstercollectie
/monsters/1 → individueel monster
/battles → gevechten (volgende week)GET /monsters/1 → de representatie van PyronWaarom deze aanpak? Monsters vormen een collectie; een monster wordt op ID aangesproken.
Wanneer kies je iets anders? Een actie-resource zoals /battles is passend voor het starten van een simulatie.
Toepassing in MonsterBattles: Een bruikbare URL maakt de API eenvoudig te begrijpen.

06Routes en endpoints
Wat is het? Een route beschrijft welk pad wordt afgehandeld. Een endpoint combineert meestal methode + pad: GET /monsters is iets anders dan POST /monsters.
Hoe werkt het? Een route koppelt een padpatroon en HTTP-methode aan een handler. Een endpoint is een concreet aanspreekbaar API-contract zoals GET /monsters/1.
routing {
get("/monsters") { call.respondText("All monsters") }
post("/monsters") { call.respondText("Monster created") }
}GET /monsters → All monsters
POST /monsters → Monster createdWaarom deze aanpak? Twee handlers kunnen hetzelfde pad hebben zolang hun HTTP-methode verschilt.
Wanneer kies je iets anders? Een losse functie is voldoende als er nog geen HTTP-server bestaat.
Toepassing in MonsterBattles: De HTTP-laag krijgt een duidelijke verantwoordelijkheid.
07GET
Wat is het? GET vraagt een representatie op. Een GET hoort geen resource te wijzigen en is daarmee safe en idempotent.
Hoe werkt het? GET vraagt gegevens op zonder de resource te wijzigen. Een bestaande resource geeft 200; een onbekend ID 404.
get("/monsters/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respond(HttpStatusCode.BadRequest)
return@get
}
call.respondText("Monster $id")
}GET /monsters/1 → Monster 1
GET /monsters/nope → 400 Bad RequestWaarom deze aanpak? GET /monsters haalt de catalogus op zonder de HP of stats van monsters te veranderen.
Wanneer kies je iets anders? Gebruik queryparameters voor zoeken in een collectie, niet een afzonderlijk endpoint per filter.
Toepassing in MonsterBattles: Monsters bekijken verandert geen opgeslagen basisstats.
08POST
Wat is het? POST verstuurt gegevens voor verwerking en wordt vaak gebruikt om een nieuwe resource te maken. POST is niet gegarandeerd idempotent.
Hoe werkt het? POST verstuurt JSON om een nieuwe resource aan te maken. De server kent het ID toe en retourneert 201 plus een representatie.
post("/monsters") {
val request = call.receive<CreateMonsterRequest>()
val created = monsterService.create(request)
call.response.header(HttpHeaders.Location, "/monsters/${created.id}")
call.respond(HttpStatusCode.Created, created)
}201 Created
Location: /monsters/3
{"id":3,"name":"Pyron", ...}Waarom deze aanpak? Bij een nieuwe Pyron kiest de backend het ID en geeft bij succes 201 Created terug.
Wanneer kies je iets anders? Gebruik PUT bij het volledig vervangen van een bestaande resource.
Toepassing in MonsterBattles: Een nieuw monster krijgt centraal een uniek ID.
09PUT
Wat is het? PUT maakt of vervangt de representatie op een bekende URL, afhankelijk van het API-contract. Herhaaldelijk dezelfde PUT heeft hetzelfde bedoelde effect.
Hoe werkt het? PUT ontvangt voor dit contract alle wijzigbare velden. Het ID komt uit het URL-pad; een bestaand monster wordt vervangen, een onbekend monster geeft 404.
PUT http://localhost:8080/monsters/1
Content-Type: application/json
{"name":"Pyron","element":"FIRE","hp":100,"attack":31,"defense":10,"speed":110}200 OK
{"id":1,"name":"Pyron","attack":31,...}Waarom deze aanpak? PUT /monsters/1 vervangt de aanpasbare monstergegevens in ons ontwerp; ontbrekende monsters geven 404.
Wanneer kies je iets anders? PATCH zou passen als alleen een paar velden wijzigen; die route bouwen we niet.
Toepassing in MonsterBattles: Wijzigingen blijven consistent wanneer later SQL wordt toegevoegd.
10PATCH
Wat is het? PATCH beschrijft een gedeeltelijke wijziging van een resource. De semantiek hangt af van het patchformaat en de route-implementatie. PATCH is niet per definitie idempotent.
Hoe werkt het? PATCH wijzigt een gedeelte van een resource. Het is conceptueel leerstof; de basis-API biedt geen PATCH-endpoint.
PATCH http://localhost:8080/monsters/1
Content-Type: application/merge-patch+json
{"attack":31}Conceptueel voorbeeld: 200 OK met bijgewerkte resource
Deze PATCH-route is NIET onderdeel van de basisimplementatie.Waarom deze aanpak? Een optionele PATCH-route zou alleen de Attack-waarde kunnen aanpassen; voor de afgesproken basis-CRUD is PUT voldoende.
Wanneer kies je iets anders? PUT blijft geschikt voor de vaste complete update-DTO.
Toepassing in MonsterBattles: We leren PATCH begrijpen zonder onnodig nieuwe spelcode te bouwen.
11DELETE
Wat is het? DELETE verwijdert een resource. Het bedoelde eindresultaat is idempotent, ook als de tweede aanroep een andere statuscode teruggeeft.
Hoe werkt het? DELETE verwijdert één resource via het ID. Na succesvolle verwijdering sturen we 204 zonder body, anders 404.
delete("/monsters/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) { call.respond(HttpStatusCode.BadRequest); return@delete }
if (monsterService.delete(id)) call.respond(HttpStatusCode.NoContent)
else call.respond(HttpStatusCode.NotFound)
}204 No Content (gevonden en verwijderd)
404 Not Found (bestaat niet)Waarom deze aanpak? DELETE /monsters/1 verwijdert Pyron; opnieuw verwijderen kan 404 opleveren.
Wanneer kies je iets anders? Gebruik soft-delete pas wanneer audit- of herstelvereisten dat vragen.
Toepassing in MonsterBattles: Een verwijderde resource verdwijnt uit het repositoryresultaat.
12CRUD
Wat is het? CRUD staat voor Create, Read, Update en Delete. Het model beschrijft basisbewerkingen; HTTP levert hiervoor gangbare methoden.
Hoe werkt het? CRUD staat voor Create, Read, Update, Delete. In HTTP correspondeert dat doorgaans met POST, GET, PUT en DELETE.
Create → POST /monsters
Read → GET /monsters
Read → GET /monsters/{id}
Update → PUT /monsters/{id}
Delete → DELETE /monsters/{id}Vijf endpoints voor vier CRUD-bewerkingen.Waarom deze aanpak? MonsterBattles implementeert POST, GET, PUT en DELETE plus GET op een enkele resource.
Wanneer kies je iets anders? Een tijdelijke demo kan een collectie direct manipuleren; niet in de echte HTTP-laag.
Toepassing in MonsterBattles: De vijf endpoints vormen een complete basis voor monsterbeheer.
13Safe HTTP methods
Wat is het? Een safe methode vraagt geen wijziging van servertoestand aan. GET, HEAD en OPTIONS zijn safe volgens de HTTP-semantiek. Logging van een request kan nog steeds plaatsvinden.
Hoe werkt het? Een veilige methode hoort de bedoelde toestand van resources niet te wijzigen. GET is safe, POST/PUT/DELETE niet; logging kan wel gebeuren.
GET http://localhost:8080/monsters/1
###
HEAD http://localhost:8080/monsters/1GET levert gegevens; HEAD hoort bij correcte ondersteuning dezelfde metadata zonder responsebody te geven.Waarom deze aanpak? Een GET-request mag geen gevecht starten of HP verminderen.
Wanneer kies je iets anders? POST is geschikt voor een operatie die wel toestand wijzigt.
Toepassing in MonsterBattles: Een client kan veilig monsterlijsten ophalen.
14Idempotency
Wat is het? Een methode is idempotent als één of meerdere identieke requests hetzelfde bedoelde effect hebben. GET, PUT en DELETE zijn idempotent; POST doorgaans niet.
Hoe werkt het? Een idempotente bewerking heeft na herhaling hetzelfde bedoelde effect als na één uitvoering. GET, PUT en DELETE zijn idempotent; POST is dat meestal niet.
PUT /monsters/1 {"attack":30} → attack = 30
PUT /monsters/1 {"attack":30} → attack = 30
POST /monsters {...} → nieuwe resource
POST /monsters {...} → mogelijk nóg een resourceIdempotent gaat over het effect, niet over identieke statuscodes.Waarom deze aanpak? Het herhaaldelijk instellen van Attack op 30 via PUT creëert geen extra monsters.
Wanneer kies je iets anders? Idempotent betekent niet dat elke response dezelfde statuscode moet hebben.
Toepassing in MonsterBattles: Het verklaart waarom herhalen van een PUT anders werkt dan herhalen van een POST.
15HTTP-statuscodes
Wat is het? Statuscodes zijn onderdeel van het API-contract: 2xx betekent succes, 4xx betekent clientfout en 5xx betekent serverfout. Gebruik codes die overeenkomen met de gebeurtenis.
Hoe werkt het? Statuscodes geven een machineleesbaar resultaat: 200 OK, 201 Created, 204 No Content, 400 Bad Request en 404 Not Found. 401/403/405/409 komen als aparte HTTP-concepten aan bod.
200 OK → succesvol opgehaald/bijgewerkt
201 Created → monster aangemaakt
204 No Content → verwijderd, geen body
400 Bad Request → ongeldige invoer
401 Unauthorized → niet geauthenticeerd
403 Forbidden → geen toegang
404 Not Found → monster bestaat niet
405 Method Not Allowed → methode niet toegestaan
409 Conflict → conflicterende bewerkingVoorbeeld: GET /monsters/999 → 404 Not FoundWaarom deze aanpak? Een onbekend monster krijgt 404, ongeldige invoer 400 en nieuwe monsters 201.
Wanneer kies je iets anders? 201 is niet correct voor een gewone GET of een update.
Toepassing in MonsterBattles: Een client kan fouten onderscheiden zonder Nederlandse foutteksten te parsen.
16Ktor Application
Wat is het? Een Ktor Application registreert plugins en routes. Met embeddedServer(Netty, ...) kun je een zelfstandige Kotlin-server starten.
Hoe werkt het? De Application heeft een configureerbare module. De entrypoint start de Netty-engine en roept de module aan; echte businesslogica staat elders.
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
configureSerialization()
configureRouting()
}.start(wait = true)
}Server luistert op http://localhost:8080Waarom deze aanpak? Alle HTTP-functionaliteit van MonsterBattles draait in dezelfde serverapplicatie.
Wanneer kies je iets anders? Een embeddedServer met een inline module kan in een klein voorbeeld, maar het project gebruikt Application.kt.
Toepassing in MonsterBattles: De backend kan via Amper draaien als zelfstandige applicatie.
17Ktor plugins
Wat is het? Een plugin voegt herbruikbare frameworkfunctionaliteit toe. ContentNegotiation verzorgt bijvoorbeeld JSON-serialisatie. Plugins configureer je bij de applicatie.
Hoe werkt het? Ktor-plugins voegen generieke serverfunctionaliteit toe. ContentNegotiation verzorgt JSON en StatusPages centrale foutresponses; de precieze pluginversie volgt de Amper-configuratie.
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = false })
}
}Ktor verwerkt Content-Type: application/json met kotlinx.serialization.Waarom deze aanpak? Zo hoeft JSON-verwerking niet handmatig in iedere route te worden geschreven.
Wanneer kies je iets anders? Extra plugins horen alleen in module.yaml als de functie echt wordt gebouwd.
Toepassing in MonsterBattles: We configureren infrastructuur op één centrale plaats.
18Routing
Wat is het? Met routing en HTTP-methodfuncties koppel je URL’s aan handlers. Routegroepen voorkomen herhaling van dezelfde paden.
Hoe werkt het? Groepeer routes onder route("/monsters") en registreer per methode een handler. De handler parseert input en delegeert naar een service.
fun Application.configureRouting() {
routing {
route("/monsters") {
get { call.respondText("All monsters") }
get("/{id}") { call.respondText("One monster") }
}
}
}GET /monsters → All monsters
GET /monsters/1 → One monsterWaarom deze aanpak? Alle monster-endpoints kunnen onder route("/monsters") worden gegroepeerd.
Wanneer kies je iets anders? Een afzonderlijke routefunctie is overzichtelijker als er meerdere endpoints zijn.
Toepassing in MonsterBattles: De route blijft klein en is goed te testen.
19Request handling
Wat is het? De route leest padparameters met call.parameters, queryparameters met call.request.queryParameters en JSON-bodies met call.receive<T>(). Valideer voordat je de servicelaag aanroept.
Hoe werkt het? Lees routeparameters met call.parameters, queryparameters met call.request.queryParameters en JSON-body met call.receive<T>(). Valideer de resultaten.
get("/monsters/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
val sort = call.request.queryParameters["sort"]
if (id == null) { call.respond(HttpStatusCode.BadRequest); return@get }
call.respondText("id=$id, sort=${sort ?: "none"}")
}GET /monsters/3?sort=speed → id=3, sort=speedWaarom deze aanpak? Het monster-ID moet een geheel getal zijn; ongeldige ID’s leveren 400.
Wanneer kies je iets anders? Gebruik een DTO in plaats van ruwe mapvelden voor vaste requestcontracten.
Toepassing in MonsterBattles: Ongeldige ID’s en bodies krijgen betrouwbare responses.
20Response handling
Wat is het? Met call.respond verstuur je objecten of statussen. call.respondText verstuurt tekst. Het framework stelt bij geconfigureerde ContentNegotiation de juiste JSON-response samen.
Hoe werkt het? Antwoord met call.respond(HttpStatusCode.OK, dto), Created bij create en NoContent na delete. Bij 204 stuur je geen body mee.
get("/health") {
call.respond(HttpStatusCode.OK, mapOf("status" to "up"))
}200 OK
{"status":"up"} (bij een passende JSON-serializer voor Map)Waarom deze aanpak? MonsterBattles antwoordt met een DTO en expliciete statuscode in plaats van losse strings.
Wanneer kies je iets anders? Een 204-response bevat geen JSON-body.
Toepassing in MonsterBattles: GET, POST, PUT en DELETE houden hun afgesproken statuscodes.
21JSON
Wat is het? JSON bestaat uit objecten, arrays, strings, numbers, booleans en null. Propertynamen staan tussen dubbele quotes. JSON is een uitwisselingsformaat, geen Kotlin-code.
Hoe werkt het? JSON gebruikt objecten, arrays, strings, getallen, booleans en null. Enumwaarden worden in het afgesproken contract als strings uitgewisseld.
{
"id": 1,
"name": "Pyron",
"element": "FIRE",
"hp": 100,
"attack": 30,
"defense": 10,
"speed": 110
}Geldige JSON-representatie van Pyron.Waarom deze aanpak? Een monster kan als JSON worden verstuurd zonder de interne Kotlin-class bloot te geven.
Wanneer kies je iets anders? Een handmatig JSON-object is bruikbaar als klein conceptueel voorbeeld; niet als API-standaard.
Toepassing in MonsterBattles: Een toekomstige Android-client kan monstergegevens lezen.
22Serialization
Wat is het? Serialization zet een Kotlin-object om naar bytes of tekst zoals JSON. Bij kotlinx.serialization gaat dat via gegenereerde serializers voor geannoteerde typen.
Hoe werkt het? Bij serialisatie zet kotlinx.serialization een DTO om naar JSON. Het domeinobject blijft vrij van HTTP-formatkeuzes.
@Serializable
data class MonsterResponse(val id: Int, val name: String)
fun main() {
println(Json.encodeToString(MonsterResponse(1, "Pyron")))
}{"id":1,"name":"Pyron"}Waarom deze aanpak? De API verstuurt een MonsterResponse als JSON, niet als de standaard toString() van een class.
Wanneer kies je iets anders? Handmatige omzetting past alleen bij zeer eenvoudige demo’s.
Toepassing in MonsterBattles: Domeinclasses hoeven geen HTTP-annotaties te krijgen.
23Deserialization
Wat is het? Deserialization zet JSON-data om naar een Kotlin-object. Ontbrekende verplichte velden of verkeerde typen veroorzaken een decodeerfout.
Hoe werkt het? Bij deserialisatie leest call.receive<T>() JSON naar een request-DTO. Ongeldige of ontbrekende invoer wordt een 400-response, niet een ongecontroleerde 500.
@Serializable
data class CreateMonsterRequest(val name: String, val element: String)
fun main() {
val json = """{"name":"Pyron","element":"FIRE"}"""
val input = Json.decodeFromString<CreateMonsterRequest>(json)
println("${input.name}: ${input.element}")
}Pyron: FIREWaarom deze aanpak? Een POST-body wordt omgezet naar CreateMonsterRequest voordat de service het monster aanmaakt.
Wanneer kies je iets anders? Laat businessvalidatie in de service of domeinconstructor plaatsvinden.
Toepassing in MonsterBattles: Verdachte JSON bereikt de service niet als geldig domeinobject.
24@Serializable
Wat is het? @Serializable markeert types waarvoor kotlinx.serialization een serializer kan genereren. Het is vooral bruikbaar voor API DTO’s en nested data classes.
Hoe werkt het? Met @Serializable kan de Kotlin-serialisatieplugin een serializer voor DTO-classes genereren. De package en imports moeten overeenkomen met het project.
@Serializable
data class StatsDto(val hp: Int, val attack: Int, val defense: Int, val speed: Int)
@Serializable
data class MonsterDto(val id: Int, val name: String, val stats: StatsDto)@Serializable maakt JSON-(de)serialisatie van deze DTO’s mogelijk.Waarom deze aanpak? Een request-DTO kan eenvoudig naar of van JSON worden omgezet.
Wanneer kies je iets anders? Serialiseer bij voorkeur DTO’s, niet een complexe domeinclass met interne toestand.
Toepassing in MonsterBattles: De API-response kan zonder handgeschreven JSON-string worden gemaakt.
25kotlinx.serialization
Wat is het? De Json-configuratie bepaalt bijvoorbeeld de behandeling van onbekende velden en de weergave van defaults. In Ktor integreer je Json via ContentNegotiation.
Hoe werkt het? Installeer Ktor ContentNegotiation met json(); zet serialization in module.yaml aan. Gebruik de bij het project passende libraryversies.
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = false
prettyPrint = true
encodeDefaults = true
})
}JSON-responses worden leesbaar ingesprongen; onbekende invoervelden worden niet genegeerd.Waarom deze aanpak? Een onbekend API-veld kan bewust worden geweigerd; dat maakt het contract voorspelbaar.
Wanneer kies je iets anders? Voor een CLI-project zonder Ktor is ContentNegotiation niet nodig.
Toepassing in MonsterBattles: Ktor gebruikt dezelfde serializer voor responses en requests.
26DTO’s
Wat is het? Een Data Transfer Object beschrijft welke data over de API-grens gaan. Een request-DTO heeft vaak geen ID, terwijl een response-DTO juist een server-ID bevat.
Hoe werkt het? Maak expliciete request- en response-DTO’s. CreateMonsterRequest heeft geen ID, MonsterResponse wel; het domein bevat een Stats-object.
@Serializable
data class CreateMonsterRequest(
val name: String, val element: Element,
val hp: Int, val attack: Int, val defense: Int, val speed: Int
)
@Serializable
data class MonsterResponse(
val id: Int, val name: String, val element: Element,
val hp: Int, val attack: Int, val defense: Int, val speed: Int
)POST-request bevat geen ID; response bevat het nieuw uitgegeven ID.Waarom deze aanpak? Intern blijft Monster vrij om extra gedrag en private state te bezitten.
Wanneer kies je iets anders? Voor interne serviceaanroepen is het domeinmodel zelf voldoende.
Toepassing in MonsterBattles: We voorkomen dat opslagdetails direct naar clients lekken.
27Repository pattern
Wat is het? Een repository abstraheert opslag. De service is afhankelijk van een contract en hoeft niet te weten of data in een MutableMap, H2 of een externe database zitten.
Hoe werkt het? Een interface beschrijft opslagoperaties zonder aan te geven of gegevens in geheugen of SQL staan. Services hoeven alleen de interface te kennen.
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
}Dezelfde vijf methoden in Week 04 en Week 07.Waarom deze aanpak? In Week 04 kan een in-memory repository worden gebruikt; later vervangen we de implementatie.
Wanneer kies je iets anders? Een concrete lijst kan voor een éénmalig script; een interface is beter zodra opslag wisselbaar moet zijn.
Toepassing in MonsterBattles: Week 07 kan H2/Exposed introduceren zonder de servicecontracten te veranderen.
28In-memory repository
Wat is het? Een in-memory repository bewaart gegevens binnen het lopende proces. Ze is handig voor ontwikkeling, maar data verdwijnen bij herstart en concurrent gebruik vraagt aandacht.
Hoe werkt het? Bewaar monsters tijdelijk in een collectie met een volgende-ID-teller. Geef snapshot-lijsten terug en valideer dat twee objecten niet dezelfde ID krijgen.
// Fragment from InMemoryMonsterRepository (full class below).
private val monsters = linkedMapOf<Int, Monster>()
private var nextId = 1
override fun findAll(): List<Monster> = monsters.values.toList()
override fun findById(id: Int): Monster? = monsters[id]
override fun create(name: String, element: Element, stats: Stats): Monster {
val monster = Monster(nextId++, name, element, stats)
monsters[monster.id] = monster
return monster
}ID-uitgifte en CRUD via hetzelfde repositorycontract.Waarom deze aanpak? We kunnen de API testen zonder direct een database toe te voegen.
Wanneer kies je iets anders? Een file- of SQL-repository kan later dezelfde interface implementeren.
Toepassing in MonsterBattles: Week 04 blijft klein en volledig testbaar zonder database.
29Service layer
Wat is het? De service voert use-cases en validatie uit. Routes vertalen HTTP naar servicecalls; repositories voeren opslagbewerkingen uit.
Hoe werkt het? De service bewaakt invoer, bepaalt wat niet gevonden betekent en laat opslag aan de repository over. Routes houden zich uitsluitend met HTTP bezig.
// Fragment from the canonical MonsterService (full class below).
class MonsterService(private val repository: MonsterRepository) {
fun list(): List<MonsterResponse> =
repository.findAll().map { it.toResponse() }
fun get(id: Int): MonsterResponse =
(repository.findById(id) ?: throw MonsterNotFoundException(id)).toResponse()
}MonsterService delegeert opslag aan create(), niet save().Waarom deze aanpak? Een MonsterService kan dubbele IDs en ongeldige stats bewaken zonder dat routes dat overal herhalen.
Wanneer kies je iets anders? Bij zeer kleine pure berekeningen volstaat een functie; CRUD heeft een service-use case.
Toepassing in MonsterBattles: Route-, service- en repositorytaken zijn gescheiden.
30API-contract
Wat is het? Een API-contract legt methode, URL, requestbody, responsebody en foutcodes vast. Clients kunnen erop vertrouwen zolang de afspraken stabiel blijven.
Hoe werkt het? Beschrijf voor elk endpoint requestvelden, responsevelden, statuscodes en foutcodes. Dit contract is de bron van waarheid voor latere weken.
POST http://localhost:8080/monsters
Content-Type: application/json
{"name":"Pyron","element":"FIRE","hp":100,"attack":30,"defense":10,"speed":110}201 Created: new monster with generated ID
400 Bad Request: invalid input
404 Not Found: unknown IDWaarom deze aanpak? We geven steeds dezelfde responsestructuur terug voor een monster.
Wanneer kies je iets anders? Pas het contract bewust aan als velden veranderen; geen stille naamvarianten.
Toepassing in MonsterBattles: De API wordt expliciet en toetsbaar vastgelegd.
31Monster CRUD
Wat is het? De vijf basisendpoints combineren de volledige keten route → service → repository. Een route zet HTTP-statussen om naar duidelijke API-responses.
Hoe werkt het? Implementeer vijf endpoints: GET collectie, GET item, POST, PUT en DELETE. Test ook 400 en 404, niet alleen de happy path.
GET http://localhost:8080/monsters
###
GET http://localhost:8080/monsters/1
###
POST http://localhost:8080/monsters
Content-Type: application/json
{"name":"Zephyra","element":"WIND","hp":90,"attack":27,"defense":9,"speed":120}
###
PUT http://localhost:8080/monsters/3
Content-Type: application/json
{"name":"Zephyra","element":"WIND","hp":90,"attack":28,"defense":9,"speed":120}
###
DELETE http://localhost:8080/monsters/3200, 200, 201, 200 en 204 bij succesvolle bewerkingen.Waarom deze aanpak? Je kunt Pyron aanmaken, ophalen, wijzigen en verwijderen.
Wanneer kies je iets anders? Bouw PATCH alleen als een concrete use case daarom vraagt.
Toepassing in MonsterBattles: Dit is de werkende Week 04-mijlpaal.


32HTTP Client
Wat is het? In IntelliJ of een andere HTTP-client kun je .http-bestanden uitvoeren. Met ### scheid je requests en kun je snel een API-contract controleren.
Hoe werkt het? Gebruik een .http-bestand in IntelliJ om requests te herhalen. Zet Content-Type: application/json bij JSON-bodies en inspecteer status én body.
@base = http://localhost:8080
GET {{base}}/monsters
Accept: application/json
###
GET {{base}}/monsters/1
Accept: application/jsonDe client laat de ontvangen statuscode, headers en JSON-body zien.Waarom deze aanpak? monsterbattles.http fungeert als handmatige collectie van voorbeeldrequests.
Wanneer kies je iets anders? Gebruik testApplication voor geautomatiseerde integratietests in plaats van alleen handmatige requests.
Toepassing in MonsterBattles: Concrete HTTP-requests maken de API verifieerbaar.
33Architectuurdiagram
Wat is het? De afhankelijkheden lopen van buiten naar binnen: de route kent de service, de service kent het repositorycontract. De repository-implementatie beheert de opslag.
Hoe werkt het? De client spreekt de route aan; de route gebruikt de service; de service gebruikt het repositorycontract. Implementaties kunnen wisselen zonder de route te veranderen.
HTTP Client
│ request
▼
Ktor MonsterRoutes
│
▼
MonsterService ──> MonsterRepository (interface)
▲
│ implements
InMemoryMonsterRepository
│
└──── response (DTO / JSON) ────> ClientRoute → Service → Repository → data → Service → DTO → ResponseWaarom deze aanpak? Ktor en persistence blijven gescheiden van de MonsterBattles-spelregels.
Wanneer kies je iets anders? Bij grotere applicaties kunnen extra lagen zinvol zijn; deze twee features vragen daar niet om.
Toepassing in MonsterBattles: Het diagram maakt afhankelijkheden duidelijk.

Geïntegreerd voorbeeld: de complete Monster CRUD API
De volgende blokken vormen samen de Week 04-app. Elk codeblok vermeldt het echte bestandspad; een afzonderlijke Ktor-route is geen zelfstandig main()-programma. De modelbestanden komen rechtstreeks overeen met Week 02. De service gebruikt het repositorycontract dat later ook met Exposed wordt geïmplementeerd.
Stap 1 — Amper: de Kotlin- en Ktor-configuratie staat in module.yaml; er is geen Gradle-configuratie.
product: jvm/app
settings:
ktor: enabled
kotlin:
serialization: enabled
jvm:
mainClass: nl.monsterbattles.api.ApplicationKt
dependencies:
- $ktor.server.core
- $ktor.server.netty
- $ktor.server.config.yaml
- ch.qos.logback:logback-classic:1.5.38
- $ktor.server.contentNegotiation
- $ktor.serialization.kotlinx.json
- $ktor.server.statusPages
test-dependencies:
- $ktor.server.testHostStap 2 — Domeinmodel: de vaste businesswaarden, onafhankelijk van HTTP en JSON. Deze modellen bestaan al vanaf Week 02.
package nl.monsterbattles.api.features.monsters.model
enum class Element { FIRE, WATER, WIND }package nl.monsterbattles.api.features.monsters.model
data class Stats(val hp: Int, val attack: Int, val defense: Int, val speed: Int) {
init {
require(hp > 0) { "HP must be positive" }
require(attack >= 0) { "Attack cannot be negative" }
require(defense >= 0) { "Defense cannot be negative" }
require(speed >= 0) { "Speed cannot be negative" }
}
}package nl.monsterbattles.api.features.monsters.model
class Monster(val id: Int, val name: String, val element: Element, val stats: Stats) {
init {
require(id > 0) { "ID must be positive" }
require(name.isNotBlank()) { "Name is required" }
}
}Stap 3 — DTO's: de client stuurt platte requestvelden. In het domein blijven deze gegroepeerd in Stats. De ID wordt uitsluitend door de repository uitgegeven. Alleen de DTO's krijgen @Serializable; enumwaarden worden als strings weergegeven.
package nl.monsterbattles.api.features.monsters.dto
import kotlinx.serialization.Serializable
import nl.monsterbattles.api.features.monsters.model.*
@Serializable
data class CreateMonsterRequest(
val name: String,
val element: Element,
val hp: Int,
val attack: Int,
val defense: Int,
val speed: Int
)
@Serializable
data class UpdateMonsterRequest(
val name: String,
val element: Element,
val hp: Int,
val attack: Int,
val defense: Int,
val speed: Int
)
@Serializable
data class MonsterResponse(
val id: Int,
val name: String,
val element: Element,
val hp: Int,
val attack: Int,
val defense: Int,
val speed: Int
)
fun Monster.toResponse() = MonsterResponse(
id, name, element, stats.hp, stats.attack, stats.defense, stats.speed
)
fun CreateMonsterRequest.toStats() = Stats(hp, attack, defense, speed)
fun UpdateMonsterRequest.toStats() = Stats(hp, attack, defense, speed)Stap 4 — Repository: beide implementaties in Week 04 en Week 07 voldoen aan hetzelfde contract. De in-memory repository seeddt Pyron en Aquara slechts één keer per nieuwe instance.
package nl.monsterbattles.api.features.monsters.repository
import nl.monsterbattles.api.features.monsters.model.*
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
}package nl.monsterbattles.api.features.monsters.repository
import nl.monsterbattles.api.features.monsters.model.*
class InMemoryMonsterRepository : MonsterRepository {
private val monsters = linkedMapOf<Int, Monster>()
private var nextId = 1
init {
create("Pyron", Element.FIRE, Stats(100, 30, 10, 110))
create("Aquara", Element.WATER, Stats(110, 25, 12, 95))
}
override fun findAll(): List<Monster> = monsters.values.toList()
override fun findById(id: Int): Monster? = monsters[id]
override fun create(name: String, element: Element, stats: Stats): Monster {
val monster = Monster(nextId++, name, element, stats)
monsters[monster.id] = monster
return monster
}
override fun update(id: Int, name: String, element: Element, stats: Stats): Monster? {
if (id !in monsters) return null
val monster = Monster(id, name, element, stats)
monsters[id] = monster
return monster
}
override fun delete(id: Int): Boolean = monsters.remove(id) != null
}Stap 5 — Service: hier zit de CRUD-usecase. De service retourneert domeinresultaten als DTO en gooit een gerichte exception als een item niet bestaat.
package nl.monsterbattles.api.features.monsters
import nl.monsterbattles.api.features.monsters.model.*
import nl.monsterbattles.api.features.monsters.dto.*
import nl.monsterbattles.api.features.monsters.repository.MonsterRepository
class MonsterNotFoundException(id: Int) : RuntimeException("Monster $id does not exist")
class MonsterService(private val repository: MonsterRepository) {
fun list(): List<MonsterResponse> = repository.findAll().map(Monster::toResponse)
fun get(id: Int): MonsterResponse =
(repository.findById(id) ?: throw MonsterNotFoundException(id)).toResponse()
fun create(request: CreateMonsterRequest): MonsterResponse {
val name = request.name.trim()
require(name.isNotBlank()) { "Name is required" }
return repository.create(name, request.element, request.toStats()).toResponse()
}
fun update(id: Int, request: UpdateMonsterRequest): MonsterResponse {
val name = request.name.trim()
require(name.isNotBlank()) { "Name is required" }
val updated = repository.update(id, name, request.element, request.toStats())
?: throw MonsterNotFoundException(id)
return updated.toResponse()
}
fun delete(id: Int) {
if (!repository.delete(id)) throw MonsterNotFoundException(id)
}
}Stap 6 — Route en uniforme errors: de route verwerkt methode, parameters, body, status en headers. StatusPages vertaalt fouten centraal. De fout-DTO is niet hetzelfde als een monsterresponse.
package nl.monsterbattles.api.common
import kotlinx.serialization.Serializable
@Serializable
data class ApiError(val code: String, val message: String)package nl.monsterbattles.api.features.monsters
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import nl.monsterbattles.api.features.monsters.dto.*
fun Route.monsterRoutes(service: MonsterService) {
route("/monsters") {
get {
call.respond(service.list())
}
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw BadRequestException("Invalid monster ID")
require(id > 0) { "ID must be positive" }
call.respond(service.get(id))
}
post {
val request = call.receive<CreateMonsterRequest>()
val created = service.create(request)
call.response.header(HttpHeaders.Location, "/monsters/${created.id}")
call.respond(HttpStatusCode.Created, created)
}
put("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw BadRequestException("Invalid monster ID")
require(id > 0) { "ID must be positive" }
call.respond(service.update(id, call.receive<UpdateMonsterRequest>()))
}
delete("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw BadRequestException("Invalid monster ID")
require(id > 0) { "ID must be positive" }
service.delete(id)
call.respond(HttpStatusCode.NoContent)
}
}
}package nl.monsterbattles.api.config
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
import kotlinx.serialization.SerializationException
import nl.monsterbattles.api.common.ApiError
import nl.monsterbattles.api.features.monsters.MonsterNotFoundException
fun Application.configurePlugins() {
install(ContentNegotiation) { json() }
install(StatusPages) {
exception<MonsterNotFoundException> { call, cause ->
call.respond(HttpStatusCode.NotFound, ApiError("MONSTER_NOT_FOUND", cause.message ?: "Monster not found"))
}
exception<IllegalArgumentException> { call, cause ->
call.respond(HttpStatusCode.BadRequest, ApiError("INVALID_INPUT", cause.message ?: "Invalid input"))
}
exception<BadRequestException> { call, _ ->
call.respond(HttpStatusCode.BadRequest, ApiError("INVALID_REQUEST", "Invalid request"))
}
exception<SerializationException> { call, _ ->
call.respond(HttpStatusCode.BadRequest, ApiError("INVALID_JSON", "Invalid JSON"))
}
exception<Throwable> { call, cause ->
environment.log.error("Unhandled request failure", cause)
call.respond(HttpStatusCode.InternalServerError, ApiError("INTERNAL_ERROR", "Internal server error"))
}
}
}Stap 7 — Applicatie-entrypoint: de module configureert Ktor en koppelt één repository aan één service. Omdat de businesslogica van de HTTP-laag gescheiden is, kunnen we later tests schrijven met fake repositories.
package nl.monsterbattles.api
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.routing.*
import nl.monsterbattles.api.config.configurePlugins
import nl.monsterbattles.api.features.monsters.*
import nl.monsterbattles.api.features.monsters.repository.InMemoryMonsterRepository
fun Application.module() {
configurePlugins()
val repository = InMemoryMonsterRepository()
val service = MonsterService(repository)
routing { monsterRoutes(service) }
}
fun main() {
embeddedServer(Netty, host = "0.0.0.0", port = 8080) {
module()
}.start(wait = true)
}suspend toevoegen maakt JDBC niet automatisch non-blocking.HTTP-requests en te verwachten responses
Start de Amper-app met ./kotlin run (Windows: kotlin.bat run) en voer vervolgens de requests uit in IntelliJ HTTP Client. De vermelde resultaten zijn verwachte resultaten: ze bewijzen pas iets nadat de server is gestart en de requests zijn uitgevoerd.
@base = http://localhost:8080
### List all
GET {{base}}/monsters
Accept: application/json
### Get Pyron
GET {{base}}/monsters/1
Accept: application/json
### Create Zephyra (receives ID 3)
POST {{base}}/monsters
Content-Type: application/json
{"name":"Zephyra","element":"WIND","hp":90,"attack":27,"defense":9,"speed":120}
### Update Zephyra
PUT {{base}}/monsters/3
Content-Type: application/json
{"name":"Zephyra","element":"WIND","hp":90,"attack":28,"defense":9,"speed":120}
### Delete Zephyra
DELETE {{base}}/monsters/3
### Deleted item is gone
GET {{base}}/monsters/3GET /monsters -> 200 OK, two seeded monsters
GET /monsters/1 -> 200 OK, Pyron
POST /monsters -> 201 Created + Location: /monsters/3
PUT /monsters/3 -> 200 OK, attack=28
DELETE /monsters/3 -> 204 No Content
GET /monsters/3 -> 404 Not Found + ApiError@base = http://localhost:8080
### Bad ID format
GET {{base}}/monsters/abc
### Missing monster
GET {{base}}/monsters/999
### Invalid stats
POST {{base}}/monsters
Content-Type: application/json
{"name":"Broken","element":"FIRE","hp":0,"attack":5,"defense":2,"speed":5}
### Missing required JSON field
POST {{base}}/monsters
Content-Type: application/json
{"name":"Incomplete"}400 INVALID_REQUEST
404 MONSTER_NOT_FOUND
400 INVALID_INPUT
400 INVALID_REQUEST (request decoding)Contractcontrole: de defaultlijst is ID-oplopend. GET /monsters?element=FIRE&sort=speed bouwen we pas in Week 05, terwijl POST /battles in Week 06 volgt. PATCH blijft dit jaar een uitgelegd HTTP-concept zonder eigen MonsterBattles-endpoint.
Cheat Sheet
| Begrip | Betekenis in MonsterBattles |
|---|---|
module.yaml | Amper-builddefinitie; geen Gradle |
GET /monsters | Lijst ontvangen (200) |
GET /monsters/{id} | Eén monster (200 of 404) |
POST /monsters | Aanmaken en ID uitgeven (201 + Location) |
PUT /monsters/{id} | Alle wijzigbare velden vervangen (200 of 404) |
DELETE /monsters/{id} | Verwijderen (204 zonder body of 404) |
PATCH | Gedeeltelijk wijzigen; alleen als concept |
| Safe / idempotent | Geen bedoelde mutatie / herhaling heeft hetzelfde bedoelde effect |
call.parameters["id"] | ID uit URL lezen |
call.receive<T>() | JSON naar request-DTO |
call.respond(status, dto) | HTTP-status en JSON terugsturen |
@Serializable | Serializer voor DTO genereren |
ContentNegotiation | JSON-parser/renderer installeren |
StatusPages | Exceptions naar uniform ApiError-formaat vertalen |
| Repository interface | Opslagcontract, onafhankelijk van implementatie |
| Service / route | Usecases versus HTTP-adapter |
| 200 / 201 / 204 | OK / Created / No Content |
| 400 / 404 / 500 | Invalid request / Not Found / server error |