Créer des API avec Kotlin : le projet
Sommaire
Introduction
Vous connaissez Kotlin côté Android ? Bonne nouvelle : le même langage permet aussi d'écrire la partie serveur d'une application. Dans cette série de deux TP, nous allons créer une API REST complète avec Ktor, le framework serveur de JetBrains (les créateurs de Kotlin).
Notre fil rouge : une API qui gère les capteurs d'un bâtiment (température, humidité, CO2…). Une application mobile, un tableau de bord ou un autre serveur pourront ensuite interroger cette API pour lister les capteurs, en ajouter, les modifier, etc.
Ce premier TP pose toutes les fondations :
- la création du projet ;
- une base de données PostgreSQL qui tourne dans Docker ;
- une organisation du code en couches, celle que vous retrouverez dans la plupart des projets professionnels ;
- une première route qui lit les capteurs en base et les renvoie en JSON.
Dans le second TP, nous compléterons l'API (création, modification, suppression), puis nous la protégerons avec un système de droits.
Un instant
Une API ne s'affiche pas joliment dans un navigateur : elle renvoie des données (ici en JSON) destinées à d'autres programmes. Pour la tester, nous utiliserons curl en ligne de commande. Vous pouvez aussi utiliser Postman ou tout autre client d'API.
Prérequis
- Connaître les bases de Kotlin. Vous venez d'un autre langage ? L'aide-mémoire Kotlin vous donnera l'essentiel.
- Savoir ce qu'est une API REST et les verbes HTTP (
GET,POST,PUT,DELETE). Au besoin, revoyez les slides d'introduction aux API et l'aide-mémoire API. - Un poste avec :
- le JDK 21 ;
- un IDE pour Kotlin : je vous conseille IntelliJ IDEA (la version gratuite suffit) ;
- Docker et Docker Compose (voir l'aide-mémoire Docker) ;
- Git (voir l'aide-mémoire Git).
Pour installer et vérifier tout cela pas à pas, suivez la page Préparer son poste : chaque étape se termine par un point de contrôle (vous pouvez ignorer la partie sur le téléphone, inutile ici).
Vous utilisez la dev-box ?
La dev-box convient parfaitement à ce TP. Il vous faut :
- Java :
devbox dev-env java; - Podman activé, pour pouvoir lancer
docker composeà l'intérieur de la dev-box (voir les bases de données) ; - les ports
8080(l'API) et8081(Adminer) ajoutés dans votrecompose.override.yaml, comme expliqué dans Voir votre site depuis le navigateur.
Inutile en revanche de lancer devbox dbs postgres : la base de ce TP est fournie par le docker-compose.yml du projet.
Objectifs
À la fin de ce TP vous saurez :
- créer un projet Ktor et comprendre sa configuration Gradle ;
- lancer une base PostgreSQL avec Docker Compose ;
- faire évoluer une base de données avec des migrations (Flyway) ;
- décrire une table et l'interroger en Kotlin avec un ORM (Exposed) ;
- organiser votre code en couches : route, service, DAO ;
- brancher le tout avec l'injection de dépendances (Koin) ;
- renvoyer du JSON depuis une route.
Le projet
Ce que nous allons construire
À la fin des deux TP, notre API proposera les routes suivantes :
| Méthode | Chemin | Description |
|---|---|---|
GET | /v1/capteurs | Lister les capteurs |
GET | /v1/capteurs/{id} | Obtenir un capteur |
POST | /v1/capteurs | Créer un capteur |
PUT | /v1/capteurs/{id} | Modifier un capteur |
DELETE | /v1/capteurs/{id} | Supprimer un capteur |
Dans ce premier TP, nous nous concentrons sur la première ligne : lister les capteurs. Ça paraît peu ? C'est voulu : pour cette seule route, nous allons mettre en place toute la structure du projet. Les routes suivantes iront ensuite beaucoup plus vite.
Pourquoi /v1 ?
Le préfixe /v1 indique la version de l'API. Le jour où vous devrez changer le format des réponses, vous créerez une /v2 sans casser les applications qui utilisent encore la /v1.
L'architecture en couches
Plutôt que de tout écrire dans un seul fichier, nous allons découper le code en couches, chacune avec un rôle précis :
Client (curl, application mobile…)
│ GET /v1/capteurs
▼
┌─────────────────────────────────────────────────────────────┐
│ Route Reçoit la requête HTTP, appelle le service, │
│ renvoie la réponse (JSON + code HTTP) │
├─────────────────────────────────────────────────────────────┤
│ Service Les règles métier (« un capteur doit avoir un nom »)│
├─────────────────────────────────────────────────────────────┤
│ DAO Les requêtes vers la base de données │
├─────────────────────────────────────────────────────────────┤
│ Table La description de la table en Kotlin (ORM) │
└─────────────────────────────────────────────────────────────┘
▼
PostgreSQL (table capteur)La règle d'or : chaque couche ne parle qu'à celle du dessous. La route ne fait jamais de SQL, le DAO ne sait pas ce qu'est une requête HTTP.
Question :
- À votre avis, quel est l'intérêt de ce découpage ? Le code ne serait-il pas plus simple dans un seul fichier ?
Réponse
Pour une seule route, un seul fichier serait effectivement plus court. Mais dès que l'API grossit :
- on s'y retrouve : un bug dans une requête SQL ? C'est dans le DAO. Une règle métier à changer ? C'est dans le service ;
- on peut changer une couche sans toucher aux autres : passer de PostgreSQL à MariaDB ne concerne que le DAO ;
- on peut tester chaque couche séparément, par exemple tester le service avec un faux DAO, sans base de données.
C'est le même principe que le découpage MVVM que vous avez peut-être vu sur Android.
Les outils
Voici les briques que nous allons assembler. Pas de panique, nous les verrons une par une :
| Outil | Rôle |
|---|---|
| Ktor | Le framework web : reçoit les requêtes HTTP et renvoie les réponses |
| kotlinx.serialization | Transforme nos objets Kotlin en JSON (et inversement) |
| PostgreSQL | La base de données, lancée dans Docker |
| Flyway | Applique les scripts SQL de création et d'évolution de la base |
| HikariCP | Le pool de connexions : garde quelques connexions ouvertes vers la base et les réutilise |
| Exposed | L'ORM : décrire les tables et écrire les requêtes en Kotlin |
| Koin | L'injection de dépendances : crée nos objets et les fournit là où on en a besoin |
Créer le projet
Le générateur
JetBrains propose un générateur de projet en ligne : start.ktor.io. Ouvrez-le et renseignez le champ Project artifact avec com.example.api-capteurs. Laissez les autres options (bouton Configure) par défaut : Gradle et Netty.

N'ajoutez aucun plugin : nous ajouterons nous-mêmes ce dont nous avons besoin, c'est le meilleur moyen de comprendre à quoi sert chaque brique. Cliquez sur Download, décompressez l'archive api-capteurs.zip, puis ouvrez le dossier dans IntelliJ IDEA.
Et la version de Ktor ?
Le générateur propose toujours la dernière version de Ktor. Ce n'est pas un problème : dans la configuration que nous allons écrire, nous fixons nous-mêmes les versions de toutes les bibliothèques. Vous êtes ainsi certains d'avoir exactement les mêmes que celles utilisées pour écrire ce TP.
Que se passe-t-il derrière ?
Le générateur fournit surtout le squelette du projet : le wrapper Gradle (gradlew, gradlew.bat, le dossier gradle/wrapper), qui télécharge automatiquement la bonne version de Gradle, et l'arborescence des dossiers. La configuration, elle, nous allons l'écrire nous-mêmes juste après.
Faire un peu de ménage
Le générateur a créé un peu de code d'exemple. Supprimez :
- les fichiers
main.ktetRouting.ktdu dossiersrc/main/kotlin; - les fichiers
application.yamletlogback.xmldu dossiersrc/main/resources; - le dossier
src/test(nous ne ferons pas de tests automatisés dans cette série de TP).
Gardez le reste : gradlew, gradlew.bat, le dossier gradle/, build.gradle.kts, settings.gradle.kts, gradle.properties et README.md (que vous pourrez compléter plus tard). Nous allons modifier certains de ces fichiers juste après.
Le générateur ne fournit pas de fichier .gitignore. Créez-le à la racine du projet : il indique à Git les fichiers à ne pas versionner.
.gradle/
build/
.idea/
.kotlin/
*.imlbuild/ et .gradle/ sont des dossiers générés par Gradle, .idea/ et *.iml des réglages propres à votre IntelliJ : ils n'ont rien à faire dans Git.
La configuration Gradle
Gradle est l'outil qui compile le projet et télécharge les bibliothèques. Il s'appuie sur trois fichiers.
Les versions des bibliothèques
Le fichier gradle/libs.versions.toml est un catalogue de versions : il regroupe en un seul endroit toutes les bibliothèques utilisées et leurs versions. Remplacez son contenu (ou créez-le) :
[versions]
kotlin = "2.3.10"
ktor = "3.4.1"
exposed = "1.1.1"
koin = "4.2.0"
flyway = "12.1.1"
hikaricp = "7.0.2"
postgresql = "42.7.9"
logback = "1.5.32"
[libraries]
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" }
ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" }
koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" }
koin-logger-slf4j = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" }
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
hikaricp = { module = "com.zaxxer:HikariCP", version.ref = "hikaricp" }
flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
flyway-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }Pourquoi un catalogue ?
Sans catalogue, les versions seraient éparpillées dans les fichiers de build. Ici, pour mettre à jour Ktor, il suffit de changer une ligne. C'est aussi ce qu'utilisent les projets Android récents : vous retrouverez ce fichier partout.
Les réglages du projet
Remplacez le contenu de settings.gradle.kts :
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "api-capteurs"rootProject.name: le nom du projet ;- le plugin
foojay-resolver-conventionpermet à Gradle de télécharger automatiquement le JDK demandé par le projet (Java 21, voir plus bas) s'il ne le trouve pas sur votre machine.
Le build
Remplacez le contenu de build.gradle.kts :
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ktor)
}
group = "com.example"
version = "1.0.0"
repositories {
mavenCentral()
}
application {
mainClass.set("com.example.ApplicationKt")
}
kotlin {
jvmToolchain(21)
}
dependencies {
// Le serveur web
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.server.status.pages)
implementation(libs.ktor.server.auth)
// L'injection de dépendances
implementation(libs.koin.ktor)
implementation(libs.koin.logger.slf4j)
// La base de données
implementation(libs.exposed.core)
implementation(libs.exposed.jdbc)
implementation(libs.postgresql)
implementation(libs.hikaricp)
implementation(libs.flyway.core)
implementation(libs.flyway.postgresql)
// Les logs
implementation(libs.logback.classic)
}Quelques explications :
plugins: le compilateur Kotlin, le plugin de sérialisation (qui génère le code de conversion en JSON) et le plugin Ktor (qui ajoute notamment la tâcherun) ;repositories: où télécharger les bibliothèques (Maven Central, le dépôt public de référence) ;application: la classe qui contient la fonctionmain, notre point d'entrée ;jvmToolchain(21): le projet est compilé pour Java 21 ;dependencies: les bibliothèques, désignées par leur nom dans le catalogue (libs.ktor.server.corecorrespond à la lignektor-server-core).
Une fois les fichiers modifiés, IntelliJ vous propose de recharger le projet Gradle (une petite icône d'éléphant en haut à droite) : acceptez. Le premier chargement télécharge toutes les bibliothèques et peut prendre quelques minutes.
Un instant
Nous déclarons dès maintenant toutes les dépendances des deux TP (y compris status-pages et auth, qui ne serviront que dans le second). Comme ça, nous n'aurons plus à toucher à Gradle.
La base de données avec Docker
Plutôt que d'installer PostgreSQL sur votre machine, nous allons le lancer dans un conteneur. Créez un fichier docker-compose.yml à la racine du projet :
services:
postgres:
image: postgres:18
environment:
POSTGRES_DB: capteurs
POSTGRES_USER: capteurs
POSTGRES_PASSWORD: capteurs
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U capteurs -d capteurs"]
interval: 2s
timeout: 5s
retries: 15
adminer:
image: adminer
ports:
- "8081:8080"
depends_on:
- postgres
volumes:
pgdata:Deux services :
postgres: la base de données. La basecapteurs, l'utilisateur et le mot de passe sont créés automatiquement au premier lancement. Les données sont conservées dans un volume (pgdata) : elles survivent à l'arrêt du conteneur ;adminer: une petite interface web pour voir le contenu de la base, disponible sur le port8081.
Lancez la stack :
docker compose up -d --waitQue se passe-t-il derrière ?
-d lance les conteneurs en arrière-plan. --wait attend que les services soient prêts : c'est le rôle du healthcheck, qui interroge PostgreSQL toutes les 2 secondes avec pg_isready. Sans lui, la commande rendrait la main alors que PostgreSQL est encore en train de démarrer, et notre serveur échouerait à s'y connecter.
Point de contrôle
La commande docker compose ps affiche les deux services démarrés, avec la mention healthy pour PostgreSQL. Ouvrez http://localhost:8081 : la page de connexion d'Adminer s'affiche.
Le port 5432 est déjà utilisé ?
Si un PostgreSQL tourne déjà sur votre machine, Docker ne pourra pas utiliser le port 5432. Arrêtez l'autre PostgreSQL, ou changez la ligne en "5433:5432" (et pensez à utiliser le port 5433 dans la configuration de l'application, plus bas).
Pour arrêter la stack plus tard : docker compose down (vos données sont conservées dans le volume). Pour repartir d'une base vide : docker compose down -v (le -v supprime aussi le volume).
Le point d'entrée
La configuration de l'application
Ktor lit sa configuration dans src/main/resources/application.conf (au format HOCON). Créez ce fichier :
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ com.example.ApplicationKt.module ]
}
}
database {
url = "jdbc:postgresql://localhost:5432/capteurs"
url = ${?DATABASE_URL}
user = "capteurs"
user = ${?DATABASE_USER}
password = "capteurs"
password = ${?DATABASE_PASSWORD}
}Que veut dire ${?PORT} ?
Chaque valeur est déclarée deux fois : d'abord une valeur par défaut, puis ${?NOM}, qui la remplace si la variable d'environnement NOM existe. En développement, les valeurs par défaut suffisent. En production, on fournit les vraies valeurs (mot de passe compris) par des variables d'environnement, sans jamais les écrire dans le code.
Ajoutez aussi src/main/resources/logback.xml, qui règle l'affichage des logs dans la console :
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
<logger name="com.zaxxer.hikari" level="WARN"/>
</configuration>L'application
Le code source va dans src/main/kotlin/com/example. Créez ce dossier (le package com.example) puis le fichier Application.kt :
package com.example
import com.example.plugins.configureRouting
import io.ktor.server.application.Application
import io.ktor.server.netty.EngineMain
fun main(args: Array<String>) {
EngineMain.main(args)
}
fun Application.module() {
configureRouting()
}maindémarre le serveur Netty, qui litapplication.conf;- la configuration indique que le module de l'application est
com.example.ApplicationKt.module: c'est la fonctionmodule()juste en dessous ; module()configure l'application. Pour l'instant, elle ne fait qu'une chose : déclarer les routes.
IntelliJ souligne configureRouting en rouge ? C'est normal : nous créons cette fonction juste après.
ApplicationKt ?
En Kotlin, une fonction peut exister en dehors de toute classe. Le compilateur la range alors dans une classe générée qui porte le nom du fichier suivi de Kt : les fonctions du fichier Application.kt se retrouvent dans ApplicationKt.
Une première route
Créez le package com.example.plugins puis le fichier Routing.kt :
package com.example.plugins
import io.ktor.server.application.Application
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("L'API Capteurs fonctionne !")
}
}
}La syntaxe se lit presque comme une phrase : « dans le routage, pour un GET sur /, répondre ce texte ». call représente l'échange en cours : la requête reçue et la réponse à envoyer.
Une fonction d'extension
fun Application.configureRouting() est une fonction d'extension : elle ajoute une méthode configureRouting() à la classe Application de Ktor. C'est ce qui permet d'écrire simplement configureRouting() dans module(). Nous allons utiliser ce principe pour chaque brique : un fichier, une fonction configureXxx().
Lancer le serveur
Dans un terminal, à la racine du projet :
./gradlew runSous Windows, dans PowerShell : .\gradlew.bat run. Vous pouvez aussi lancer la fonction main directement depuis IntelliJ, avec la flèche verte dans la marge.
Point de contrôle
Les logs se terminent par Responding at http://0.0.0.0:8080. Ouvrez http://localhost:8080 : le message « L'API Capteurs fonctionne ! » s'affiche.
Arrêtez le serveur (Ctrl + C) : nous allons maintenant le relier à la base de données.
Point étape : Git
Avant d'aller plus loin, c'est le bon moment pour initialiser le dépôt et faire un premier commit :
git init
git add .
git commit -m "Création du projet Ktor"Point de contrôle
La commande git status ne doit lister ni le dossier build/, ni le dossier .gradle/. Si c'est le cas, vérifiez votre fichier .gitignore.
La base de données : les migrations
Le principe
Comment créer les tables ? Vous pourriez les créer à la main dans Adminer… mais comment vos collègues, ou le serveur de production, obtiendraient-ils la même structure ?
La solution : les migrations. Ce sont des scripts SQL numérotés, rangés dans le projet, qui décrivent chaque évolution de la base. Au démarrage du serveur, Flyway regarde quels scripts ont déjà été appliqués (il le note dans une table flyway_schema_history) et exécute les nouveaux, dans l'ordre.
src/main/resources/db/migration/
├── V1__create_capteur.sql ← appliqué au premier démarrage
├── V2__create_salle.sql ← appliqué au démarrage suivant (TP 2)
└── V3__...Le nom des fichiers suit une convention stricte : V + numéro + deux tirets bas __ + description.
La règle d'or des migrations
Une migration déjà appliquée ne se modifie jamais. Flyway vérifie une somme de contrôle de chaque fichier : si un script déjà passé a changé, le serveur refuse de démarrer. Pour corriger ou faire évoluer la base, on écrit une nouvelle migration.
La première migration
Créez le dossier src/main/resources/db/migration puis le fichier V1__create_capteur.sql :
CREATE TABLE capteur (
id SERIAL PRIMARY KEY,
nom VARCHAR(100) NOT NULL,
type VARCHAR(30) NOT NULL,
unite VARCHAR(10) NOT NULL,
actif BOOLEAN NOT NULL DEFAULT TRUE
);
INSERT INTO capteur (nom, type, unite) VALUES
('Salle serveur', 'temperature', '°C'),
('Atelier', 'humidite', '%'),
('Salle de réunion', 'co2', 'ppm');La migration crée la table et y insère trois capteurs, pour que nous ayons des données à afficher.
Se connecter à la base au démarrage
Créez le package com.example.database puis le fichier Database.kt :
package com.example.database
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationStopped
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.v1.jdbc.Database
fun Application.configureDatabase() {
val config = environment.config
// 1. Le pool de connexions
val dataSource = HikariDataSource(HikariConfig().apply {
jdbcUrl = config.property("database.url").getString()
username = config.property("database.user").getString()
password = config.property("database.password").getString()
maximumPoolSize = 5
})
// 2. Les migrations
Flyway.configure()
.dataSource(dataSource)
.load()
.migrate()
// 3. Exposed utilisera ce pool pour toutes ses requêtes
Database.connect(dataSource)
// 4. On ferme proprement le pool quand le serveur s'arrête
monitor.subscribe(ApplicationStopped) {
dataSource.close()
}
}Quatre étapes, dans cet ordre :
- Le pool de connexions (HikariCP). Ouvrir une connexion à une base de données est lent. Le pool en ouvre quelques-unes (5 au maximum ici) et les réutilise d'une requête à l'autre. Les paramètres viennent de
application.conf. - Les migrations. Flyway cherche par défaut ses scripts dans
db/migrationet applique ceux qui manquent. - La connexion d'Exposed. L'ORM utilisera le pool pour toutes les requêtes.
- La fermeture du pool à l'arrêt du serveur, pour ne pas laisser de connexions ouvertes.
Ajoutez l'appel dans Application.kt, avec son import :
package com.example
import com.example.database.configureDatabase
import com.example.plugins.configureRouting
import io.ktor.server.application.Application
import io.ktor.server.netty.EngineMain
fun main(args: Array<String>) {
EngineMain.main(args)
}
fun Application.module() {
configureDatabase()
configureRouting()
}Relancez le serveur avec ./gradlew run.
Point de contrôle
Dans les logs, vous devez voir la migration s'appliquer, puis le serveur démarrer :

Dans Adminer, connectez-vous avec : système PostgreSQL, serveur postgres, utilisateur capteurs, mot de passe capteurs, base capteurs.

La table capteur contient trois lignes :

Et la table flyway_schema_history contient une ligne pour notre migration :

Question :
- Relancez le serveur une seconde fois. Que se passe-t-il pour la migration ? Les trois capteurs sont-ils insérés en double ?
Réponse
Non. Au second démarrage, Flyway voit dans flyway_schema_history que la version 1 est déjà appliquée : il n'exécute rien (le log indique Schema "public" is up to date. No migration necessary.). C'est tout l'intérêt de cette table d'historique.
Serveur : serveur postgres ?
Dans Adminer, le serveur s'appelle postgres et non localhost : Adminer tourne lui-même dans un conteneur, et dans le réseau créé par Docker Compose, chaque service est joignable par son nom. Notre application, elle, tourne sur votre machine : elle passe par localhost:5432, le port publié par Docker.
Le modèle de données
L'objet renvoyé par l'API
Nous allons maintenant écrire nos couches, de bas en haut. Commençons par ce que l'API renverra : un capteur. Créez le package com.example.sensor (toutes les classes liées aux capteurs y seront rangées) puis le fichier Sensor.kt :
package com.example.sensor
import kotlinx.serialization.Serializable
// Ce que l'API renvoie
@Serializable
data class Sensor(
val id: Int,
val name: String,
val type: String,
val unit: String,
val active: Boolean
)- Une
data classest l'équivalent d'une structure : elle regroupe des données. @Serializabledemande au plugin de sérialisation de générer le code qui convertit cet objet en JSON (et inversement). Sans cette annotation, Ktor ne saurait pas l'envoyer.
Pourquoi des noms en anglais ?
Comme dans les autres TP, le code (classes, variables, fonctions) est écrit en anglais, et la base de données (tables, colonnes) en français. La couche suivante va justement faire le lien entre les deux.
La table, côté Kotlin
Pour interroger la table capteur, Exposed a besoin de connaître sa structure. Créez SensorTable.kt dans le même package :
package com.example.sensor
import org.jetbrains.exposed.v1.core.Table
object SensorTable : Table("capteur") {
val id = integer("id").autoIncrement()
val name = varchar("nom", 100)
val type = varchar("type", 30)
val unit = varchar("unite", 10)
val active = bool("actif")
override val primaryKey = PrimaryKey(id)
}Table("capteur"): le nom de la table en base ;- chaque propriété décrit une colonne : son type (
integer,varchar,bool…) et son nom en base. C'est ici que se fait le lienname(Kotlin) ↔nom(SQL) ; objectcrée une instance unique : il n'y a qu'une tablecapteur, il n'y a donc qu'unSensorTable.
Exposed ne crée pas la table
Cette description doit correspondre exactement à la migration SQL : c'est Flyway qui crée la table, Exposed se contente de la décrire pour pouvoir l'interroger. Si vous ajoutez une colonne, il faudra modifier les deux : une nouvelle migration, et SensorTable.
Question :
- Quel est le rôle d'un ORM ? Quel avantage par rapport à des requêtes SQL écrites dans des chaînes de caractères ?
Réponse
Un ORM (Object-Relational Mapping) fait le lien entre les tables de la base et les objets du langage. Avec Exposed, les requêtes s'écrivent en Kotlin : le compilateur vérifie les noms de colonnes et les types. Une faute de frappe dans un nom de colonne devient une erreur de compilation, au lieu d'une erreur au moment de l'exécution. Et comme les valeurs sont transmises en paramètres, les injections SQL sont évitées.
Le DAO : les requêtes
Le DAO (Data Access Object) contient les requêtes vers la base. Créez SensorDao.kt :
package com.example.sensor
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
interface SensorDao {
fun findAll(): List<Sensor>
}
class ExposedSensorDao : SensorDao {
override fun findAll(): List<Sensor> = transaction {
SensorTable.selectAll()
.orderBy(SensorTable.id)
.map { it.toSensor() }
}
// Transforme une ligne de la base en objet Sensor
private fun ResultRow.toSensor() = Sensor(
id = this[SensorTable.id],
name = this[SensorTable.name],
type = this[SensorTable.type],
unit = this[SensorTable.unit],
active = this[SensorTable.active]
)
}Prenons le temps de lire ce code :
- L'interface
SensorDaodécrit ce que sait faire le DAO. La classeExposedSensorDaodécrit comment elle le fait, avec Exposed. transaction { }: toute requête Exposed doit se trouver dans une transaction. Le bloc emprunte une connexion au pool, exécute les requêtes, valide (commit) puis rend la connexion. En cas d'erreur, il annule tout (rollback).SensorTable.selectAll().orderBy(SensorTable.id)est l'équivalent deSELECT * FROM capteur ORDER BY id..map { it.toSensor() }transforme chaque ligne du résultat (ResultRow) en objetSensor.this[SensorTable.name]lit la valeur d'une colonne dans une ligne.
Question :
- Pourquoi passer par une interface ?
ExposedSensorDaoseul ne suffirait-il pas ?
Réponse
Le service (la couche du dessus) va dépendre de l'interface SensorDao, pas de l'implémentation. Il ne sait donc pas que les données viennent d'Exposed et de PostgreSQL. Avantages :
- pour tester le service, on peut lui donner un faux DAO qui renvoie des données en dur, sans base de données ;
- pour changer de technique d'accès aux données, on écrit une nouvelle implémentation sans toucher au service.
Le service : les règles métier
Créez SensorService.kt :
package com.example.sensor
class SensorService(private val sensorDao: SensorDao) {
fun getAll(): List<Sensor> = sensorDao.findAll()
}Pour l'instant, le service se contente de transmettre l'appel au DAO. Il paraît inutile ? Il se remplira dans le prochain TP : c'est lui qui vérifiera qu'un capteur existe, que son nom n'est pas vide, etc.
Remarquez que le service reçoit son DAO dans son constructeur : il ne le crée pas lui-même. Mais alors, qui crée le DAO et le donne au service ?
L'injection de dépendances avec Koin
C'est le rôle de Koin. On lui décrit comment construire nos objets, et il les crée puis les fournit là où on en a besoin. Créez SensorModule.kt :
package com.example.sensor
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val sensorModule = module {
singleOf(::ExposedSensorDao) bind SensorDao::class
singleOf(::SensorService)
}singleOf(::ExposedSensorDao) bind SensorDao::class: « crée une seule instance deExposedSensorDao, et fournis-la à quiconque demande unSensorDao» ;singleOf(::SensorService): « crée une seule instance deSensorService». Koin voit que son constructeur demande unSensorDao: il lui donne automatiquement celui déclaré juste au-dessus.
Il reste à démarrer Koin. Créez plugins/Koin.kt :
package com.example.plugins
import com.example.sensor.sensorModule
import io.ktor.server.application.Application
import io.ktor.server.application.install
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
fun Application.configureKoin() {
install(Koin) {
slf4jLogger()
modules(sensorModule)
}
}install(...) est la façon d'ajouter un plugin à Ktor. Ici, le plugin Koin, avec la liste de nos modules.
Que se passe-t-il derrière ?
Sans injection de dépendances, il faudrait écrire quelque part val service = SensorService(ExposedSensorDao()), et faire passer ce service à toutes les routes qui en ont besoin. Avec Koin, chaque classe déclare simplement ce dont elle a besoin (dans son constructeur), et Koin s'occupe de « câbler » l'ensemble. Quand le projet compte des dizaines de classes, la différence est énorme.
Le JSON
Pour que Ktor sache transformer nos objets en JSON, il faut installer le plugin ContentNegotiation. Créez plugins/Serialization.kt :
package com.example.plugins
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import kotlinx.serialization.json.Json
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
})
}
}prettyPrint = true indente le JSON produit. C'est plus lisible pendant le développement (en production, on le retire souvent pour alléger les réponses).
La route
Dernière couche : la route. Créez SensorRoutes.kt dans le package sensor :
package com.example.sensor
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import org.koin.ktor.ext.inject
fun Route.sensorRoutes() {
val sensorService by inject<SensorService>()
route("/capteurs") {
get {
call.respond(sensorService.getAll())
}
}
}by inject<SensorService>()demande le service à Koin ;route("/capteurs") { ... }regroupe toutes les routes qui commencent par/capteurs;call.respond(...)envoie la liste : grâce à ContentNegotiation, elle est automatiquement convertie en JSON.
Branchez ces routes dans plugins/Routing.kt, sous le préfixe /v1 :
package com.example.plugins
import com.example.sensor.sensorRoutes
import io.ktor.server.application.Application
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("L'API Capteurs fonctionne !")
}
route("/v1") {
sensorRoutes()
}
}
}Et enfin, complétez Application.kt :
package com.example
import com.example.database.configureDatabase
import com.example.plugins.configureKoin
import com.example.plugins.configureRouting
import com.example.plugins.configureSerialization
import io.ktor.server.application.Application
import io.ktor.server.netty.EngineMain
fun main(args: Array<String>) {
EngineMain.main(args)
}
fun Application.module() {
configureKoin()
configureDatabase()
configureSerialization()
configureRouting()
}Question :
- Pourquoi
configureKoin()est-il appelé en premier, etconfigureRouting()en dernier ?
Réponse
Les routes demandent le SensorService à Koin : Koin doit donc être démarré avant que les routes soient déclarées. De même, la base doit être prête avant qu'une requête ne l'utilise. On configure d'abord les fondations, puis ce qui s'appuie dessus.
Tester
Relancez le serveur (./gradlew run), puis dans un autre terminal :
curl http://localhost:8080/v1/capteursPoint de contrôle
Vous obtenez la liste des trois capteurs, en JSON :

Une route GET peut aussi s'ouvrir dans le navigateur : essayez http://localhost:8080/v1/capteurs.

curl sous Windows
Dans PowerShell, curl est un alias vers une autre commande, qui n'accepte pas les mêmes options. Tapez curl.exe à la place de curl (la vraie commande est installée avec Windows 10 et 11).
C'est à vous de jouer ! Ajoutez un capteur directement en base, depuis Adminer (menu « Nouvel élément » sur la table capteur), puis rappelez la route : votre capteur doit apparaître.
Point étape : Git
git add .
git commit -m "Liste des capteurs : migration, Exposed, Koin et première route"Récapitulatif
Faisons le point sur l'organisation du projet :
api-capteurs/
├── .gitignore Ce que Git doit ignorer
├── docker-compose.yml PostgreSQL + Adminer
├── settings.gradle.kts Le nom du projet, le téléchargement du JDK
├── build.gradle.kts Le build
├── gradle/libs.versions.toml Les versions des bibliothèques
└── src/main/
├── resources/
│ ├── application.conf Port, base de données
│ ├── logback.xml Les logs
│ └── db/migration/
│ └── V1__create_capteur.sql
└── kotlin/com/example/
├── Application.kt Point d'entrée : assemble les briques
├── database/Database.kt Pool, migrations, Exposed
├── plugins/ Une brique de configuration par fichier
│ ├── Koin.kt
│ ├── Routing.kt
│ └── Serialization.kt
└── sensor/ Tout ce qui concerne les capteurs
├── Sensor.kt Le modèle (JSON)
├── SensorTable.kt La table (Exposed)
├── SensorDao.kt Les requêtes
├── SensorService.kt Les règles métier
├── SensorModule.kt La déclaration pour Koin
└── SensorRoutes.kt Les routes HTTPLe trajet d'une requête GET /v1/capteurs :
- Ktor trouve la route correspondante dans
SensorRoutes.kt; - la route appelle
SensorService.getAll(), fourni par Koin ; - le service appelle
SensorDao.findAll(); - le DAO exécute
SELECT * FROM capteur ORDER BY idgrâce à Exposed, dans une transaction ; - chaque ligne est transformée en
Sensor; - la liste remonte jusqu'à la route, qui la renvoie en JSON.
Conclusion
Dans ce TP, vous avez :
- créé un projet Ktor et configuré Gradle avec un catalogue de versions ;
- lancé PostgreSQL et Adminer avec Docker Compose ;
- créé la base avec une migration Flyway ;
- décrit une table et écrit une requête avec Exposed ;
- organisé le code en couches (route, service, DAO) ;
- relié ces couches avec Koin ;
- renvoyé du JSON depuis une route.
Cela fait beaucoup de fichiers pour une seule route, mais la structure est maintenant en place. Dans le TP suivant, vous verrez que chaque nouvelle fonctionnalité se glisse naturellement dans cette organisation : nous allons compléter le CRUD des capteurs, créer une seconde ressource en autonomie, puis protéger l'API avec un système de droits.
N'oubliez pas votre dernier commit avant de passer à la suite !
Le projet complet
Le projet complet de ce TP est téléchargeable ici. Pour le lancer, depuis le dossier décompressé :
docker compose up -d --wait
./gradlew runSous Windows, dans PowerShell : .\gradlew.bat run.
👋 Si vous avez des questions, n'hésitez pas.