Développement Mobile

Consommer une API REST en Kotlin avec Retrofit 3 et OkHttp pas à pas

10 min de lecture

Toute application Android sérieuse finit par causer avec une API REST distante. La couche réseau standard en 2026 s’appelle Retrofit 3, accompagnée de OkHttp 4.12 pour le transport et de kotlinx.serialization pour le parsing JSON. Ce trio remplace définitivement Gson, Moshi et Volley dans la quasi-totalité des nouveaux projets. Ce tutoriel détaille la mise en place complète depuis zéro : dépendances Gradle, configuration OkHttp, interface Retrofit, sérialisation JSON, gestion des erreurs, et branchement à un Repository qui alimente un ViewModel. L’exemple file rouge : consommer une API REST de blog qui expose une liste d’articles et leur détail.

Prérequis

  • Projet Android Compose configuré (cf. Installer Android Studio Otter 3)
  • ViewModel + StateFlow en place (cf. ViewModel et StateFlow)
  • Notions de coroutines et de suspend functions
  • Une API REST publique pour tester (par exemple jsonplaceholder.typicode.com ou votre propre backend)
  • Temps estimé : 90 minutes

Étape 1 — Ajouter les dépendances

Retrofit 3 est sorti en mai 2025 et reste la solution de référence en mai 2026. Il s’appuie sur OkHttp 4.12 (la version 5 stable n’est pas encore intégrée à Retrofit mainline). Pour le JSON, on prend kotlinx.serialization, plus rapide et plus sûr que Gson grâce à la génération de code à la compilation.

[versions]
retrofit = "3.0.0"
okhttp = "4.12.0"
kotlinxSerialization = "1.10.0"
kotlinxSerializationConverter = "1.0.0"

[libraries]
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Dans app/build.gradle.kts, ajoutez alias(libs.plugins.kotlin.serialization) au bloc plugins et les quatre dépendances dans dependencies. Synchronisez. Le plugin kotlin-serialization active la génération des sérialiseurs à la compilation pour chaque data class annotée @Serializable.

Étape 2 — Déclarer les modèles JSON

Pour chaque ressource exposée par l’API, on crée une data class Kotlin annotée @Serializable. Le compilateur génère automatiquement le code de sérialisation/désérialisation à la compilation, sans réflexion runtime. C’est plus rapide, plus léger en APK, et plus sûr (erreurs détectées au build, pas à l’exécution).

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ArticleDto(
    val id: Int,
    val title: String,
    @SerialName("body") val contenu: String,
    @SerialName("userId") val auteurId: Int
)

@Serializable
data class CommentaireDto(
    val id: Int,
    val name: String,
    val email: String,
    val body: String
)

L’annotation @SerialName permet de renommer un champ entre le JSON (body, userId) et le Kotlin (contenu, auteurId) — utile quand on veut un nom français en interne mais qu’on consomme une API anglophone. Les DTOs (Data Transfer Objects) restent dans une couche de données séparée, distincte des modèles de domaine ; on les mappe vers Article métier dans le Repository.

Étape 3 — Définir l’interface Retrofit

L’interface Retrofit déclare les endpoints REST sous forme de fonctions Kotlin annotées. Retrofit génère l’implémentation au runtime à partir des annotations. Les fonctions sont suspend : elles s’appellent depuis une coroutine et renvoient directement la valeur, sans callback. C’est l’apport majeur des dernières versions Retrofit.

interface BlogApi {

    @GET("posts")
    suspend fun listerArticles(): List<ArticleDto>

    @GET("posts/{id}")
    suspend fun obtenirArticle(@Path("id") id: Int): ArticleDto

    @GET("posts/{id}/comments")
    suspend fun listerCommentaires(@Path("id") articleId: Int): List<CommentaireDto>

    @POST("posts")
    suspend fun publierArticle(@Body article: ArticleDto): ArticleDto

    @GET("posts")
    suspend fun rechercher(@Query("userId") userId: Int): List<ArticleDto>
}

Quatre annotations principales. @GET, @POST, @PUT, @DELETE définissent la méthode HTTP. @Path substitue une variable dans l’URL. @Query ajoute un paramètre ?userId=1. @Body sérialise un objet en JSON pour le corps de la requête. Retrofit gère automatiquement les en-têtes Content-Type: application/json et Accept: application/json via le converter.

Étape 4 — Configurer OkHttp et Retrofit

Retrofit a besoin d’un client HTTP sous-jacent (OkHttp) et d’un converter pour parser le JSON. On centralise la configuration dans un objet ou un module Hilt unique par application. Voici une configuration de référence avec timeout, logging en debug, et retry automatique.

import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit

object RetrofitFactory {

    private const val BASE_URL = "https://jsonplaceholder.typicode.com/"

    private val json = Json {
        ignoreUnknownKeys = true
        coerceInputValues = true
        encodeDefaults = true
    }

    private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(15, TimeUnit.SECONDS)
        .readTimeout(20, TimeUnit.SECONDS)
        .writeTimeout(20, TimeUnit.SECONDS)
        .retryOnConnectionFailure(true)
        .apply {
            if (BuildConfig.DEBUG) {
                addInterceptor(HttpLoggingInterceptor().apply {
                    level = HttpLoggingInterceptor.Level.BODY
                })
            }
        }
        .build()

    val blogApi: BlogApi by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
            .build()
            .create(BlogApi::class.java)
    }
}

Trois choix de configuration importants. ignoreUnknownKeys = true évite un crash si l’API ajoute un champ qu’on ne lit pas — bon pour la robustesse. Le HttpLoggingInterceptor en debug uniquement affiche les requêtes/réponses dans Logcat, indispensable pour déboguer. Les timeouts à 15-20 s sont des valeurs équilibrées : assez longues pour des connexions lentes, assez courtes pour ne pas figer l’UI.

Étape 5 — Mapper DTO vers domaine dans un Repository

Le Repository orchestre l’appel réseau et convertit le DTO en modèle de domaine. Cette séparation permet de changer d’API sans toucher au reste du code, et de tester le Repository avec un faux BlogApi.

data class Article(
    val id: Int,
    val titre: String,
    val contenu: String
)

class ArticlesRepository(
    private val api: BlogApi
) {
    suspend fun listerArticles(): List<Article> {
        return api.listerArticles().map { it.toArticle() }
    }

    suspend fun obtenirArticle(id: Int): Article {
        return api.obtenirArticle(id).toArticle()
    }

    private fun ArticleDto.toArticle() = Article(
        id = id,
        titre = title,
        contenu = contenu
    )
}

Le Repository expose un contrat propre (Article domaine, jamais ArticleDto) au ViewModel. La fonction d’extension toArticle() isole la conversion. Pour mocker dans un test : injecter un faux BlogApi ou directement un FakeArticlesRepository. C’est l’avantage architectural majeur de cette couche d’abstraction.

Étape 6 — Gérer les erreurs réseau proprement

Retrofit lève deux familles d’exceptions. HttpException quand le serveur répond avec un code 4xx/5xx (la requête a réussi côté transport mais le serveur a renvoyé une erreur applicative). IOException quand le transport lui-même échoue (pas de réseau, timeout, DNS). On gère les deux séparément pour donner un message utile à l’utilisateur.

sealed interface Resultat<out T> {
    data class Succes<T>(val data: T) : Resultat<T>
    data class ErreurReseau(val message: String) : Resultat<Nothing>
    data class ErreurServeur(val code: Int, val message: String) : Resultat<Nothing>
}

suspend fun <T> appelSecurise(bloc: suspend () -> T): Resultat<T> {
    return try {
        Resultat.Succes(bloc())
    } catch (e: HttpException) {
        Resultat.ErreurServeur(e.code(), e.message())
    } catch (e: IOException) {
        Resultat.ErreurReseau(e.message ?: "Connexion impossible")
    }
}

Cette fonction utilitaire enveloppe n’importe quel appel suspend dans un Resultat typé. Dans le Repository : suspend fun listerArticles(): Resultat<List<Article>> = appelSecurise { api.listerArticles().map { it.toArticle() } }. Côté ViewModel, on pattern-matche sur le Resultat et on met à jour le UiState en conséquence. Plus de try/catch éparpillés.

Étape 7 — Configurer la sécurité réseau (HTTPS et cleartext)

Depuis Android 9 (API 28), le cleartext HTTP est bloqué par défaut. Pour appeler une API HTTPS de production, rien à configurer. Pour développer contre un serveur local (http://10.0.2.2:8080 depuis l’émulateur ou http://192.168.1.x:8080 depuis un téléphone), créez un fichier res/xml/network_security_config.xml :

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">10.0.2.2</domain>
        <domain includeSubdomains="true">192.168.1.10</domain>
    </domain-config>
</network-security-config>

Référencez ce fichier dans AndroidManifest.xml via android:networkSecurityConfig="@xml/network_security_config" sur la balise application. Cette configuration autorise le HTTP en clair uniquement vers les domaines listés, le reste reste forcé en HTTPS. En production, n’autorisez jamais le cleartext global.

Étape 8 — Authentification par token et refresh automatique

La plupart des APIs réelles exigent un token JWT en en-tête Authorization. L’approche propre consiste à isoler la lecture du token dans un TokenProvider (interface) et à intercepter chaque requête sortante via un Interceptor OkHttp qui injecte l’en-tête. Quand le token expire (réponse 401), un second Authenticator demande un refresh automatique et rejoue la requête initiale, sans que l’UI ne le sache.

class AuthInterceptor(
    private val tokenProvider: TokenProvider
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokenProvider.lireToken()
        val requete = chain.request().newBuilder()
            .apply { if (token != null) addHeader("Authorization", "Bearer " + token) }
            .build()
        return chain.proceed(requete)
    }
}

class TokenAuthenticator(
    private val tokenProvider: TokenProvider,
    private val refreshApi: RefreshApi
) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        val ancienToken = tokenProvider.lireToken() ?: return null
        val nouveau = runBlocking { refreshApi.rafraichir(ancienToken) } ?: return null
        tokenProvider.ecrireToken(nouveau.token)
        return response.request.newBuilder()
            .header("Authorization", "Bearer " + nouveau.token)
            .build()
    }
}

L’Authenticator est appelé automatiquement par OkHttp à chaque réponse 401. Renvoyer null stoppe le retry. Renvoyer une nouvelle requête avec le token frais relance l’appel initial. Cette mécanique évite que chaque écran ait à gérer manuellement l’expiration du token.

Erreurs fréquentes

Symptôme Cause Solution
SerializationException Field X is required Champ obligatoire absent du JSON Rendre le champ nullable ou ajouter une valeur par défaut
UnknownHostException Pas d’accès Internet ou URL incorrecte Vérifier connectivité, BASE_URL et permission INTERNET
CLEARTEXT communication not permitted HTTP en clair vers domaine non autorisé Passer en HTTPS ou configurer network_security_config.xml
Réponse 401 sans token En-tête d’auth manquant Ajouter un Interceptor qui injecte le Bearer token
Réponse vide ou null inattendu Type Kotlin non nullable face à un null JSON Utiliser String? ou ajouter coerceInputValues = true

Adapter aux connexions instables

En contexte de mobilité réelle, la connexion change vite. Trois pratiques améliorent la résilience. Premièrement, ajouter un Interceptor de retry qui rejoue les requêtes idempotentes (GET) avec backoff exponentiel. Deuxièmement, mettre en cache les réponses GET avec le Cache intégré d’OkHttp (10 Mo dans cacheDir couvre largement). Troisièmement, persister les données réussies dans Room pour offrir une lecture hors-ligne instantanée.

Pour le suivi de la connectivité, ConnectivityManager.NetworkCallback notifie en temps réel des changements. Le ViewModel expose un StateFlow estEnLigne que l’UI affiche via une bannière. L’utilisateur sait alors pourquoi rien ne charge.

Foire aux questions

Retrofit ou Ktor Client ?
Retrofit en mono-plateforme Android. Ktor Client pour Kotlin Multiplatform.

kotlinx.serialization ou Moshi ?
kotlinx.serialization pour tout nouveau projet : intégré au compilateur Kotlin, plus rapide, et compatible KMP.

Comment ajouter un token JWT à toutes les requêtes ?
Un Interceptor OkHttp ajouté au client : il intercepte chaque requête, lit le token, l’ajoute en en-tête Authorization: Bearer.

Faut-il un test unitaire par endpoint ?
Pas par endpoint. On teste plutôt le Repository avec un faux BlogApi. Quelques tests d’intégration avec MockWebServer couvrent la sérialisation.

Comment uploader un fichier ?
Avec @Multipart et @Part MultipartBody.Part. Pour de gros fichiers, préférer une URL signée pré-générée et un PUT direct vers S3/R2/MinIO.

Combien de requêtes parallèles tolérer ?
OkHttp limite à 64 requêtes parallèles globales et 5 par hôte par défaut. Si l’API impose un rate-limit serré, baisser via Dispatcher().apply { maxRequestsPerHost = 2 }.

Le logging interceptor en production ?
Jamais. Il logue tout. Le conditionner strictement à BuildConfig.DEBUG.

Pour aller plus loin

Les appels API en place, l’étape logique suivante consiste à persister les données localement pour un mode hors-ligne et un cache rapide. Le tutoriel Persistance locale avec Room détaille la mise en place de Room 3.0 et son intégration au Repository. Pour la vue panoramique, voir le guide principal Kotlin et Jetpack Compose.

Ressources et références

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité