Toute app finit par dialoguer avec un serveur. Récupérer une liste, soumettre un formulaire, télécharger un fichier — autant de scénarios qui passent par le réseau. La couche officielle de Swift pour ces opérations s’appelle URLSession. Depuis Swift 5.5 et iOS 15, elle expose une API async/await complète qui rend le code réseau aussi linéaire qu’un appel synchrone, sans l’inertie des completion handlers. Ce tutoriel construit pas à pas un client REST minimaliste qui consomme une API publique, gère les erreurs et alimente une vue SwiftUI.
📘 Guide principal de la série : Développer une application iOS avec Swift et SwiftUI : panorama 2026. Le tutoriel sur la macro @Observable est un complément utile pour structurer le view model client.
Prérequis
- Xcode 26 sur macOS Tahoe.
- Cible iOS 15 minimum pour async/await sur URLSession.
- Bases du langage Swift : structs, optionals, closures — voir le tutoriel sur les bases.
- Une connexion internet pour tester contre une API publique.
- Temps estimé : 70 à 100 minutes.
Étape 1 — Choisir une API de test
Pour apprendre sans s’authentifier, plusieurs APIs publiques offrent un endpoint stable et lisible. Dans ce tutoriel, on utilise https://jsonplaceholder.typicode.com, un service maintenu par la communauté qui renvoie des données factices pour les tests : posts, users, comments, todos. Aucun token requis, latence faible, schémas simples.
Un appel curl rapide permet de voir la forme des données :
curl https://jsonplaceholder.typicode.com/posts/1
La réponse est un objet JSON avec quatre champs : userId, id, title, body. C’est exactement le format qu’on va modéliser en Swift via une struct Codable. Le statut HTTP renvoyé est 200 et le content-type application/json; charset=utf-8.
Étape 2 — Modéliser la réponse en Swift
Le décodage JSON automatique passe par le protocole Codable. Une struct conforme à Codable peut être instanciée depuis un Data JSON sans code de parsing manuel. Les noms des propriétés Swift doivent correspondre aux clés JSON, ou être mappées via CodingKeys.
struct Post: Codable, Identifiable {
let userId: Int
let id: Int
let title: String
let body: String
}
Cette struct compile telle quelle. Codable est un alias pour Encodable & Decodable — la struct peut être à la fois encodée et décodée. Identifiable permet à ForEach et List de SwiftUI de gérer la liste sans id: explicite. La propriété id de Codable correspond au champ JSON id, donc Identifiable est satisfait gratuitement.
Étape 3 — Écrire le client réseau
Le client centralise la logique des appels : construction de l’URL, exécution via URLSession, décodage du JSON, gestion des erreurs. L’isoler dans une struct ou une classe dédiée facilite les tests et la réutilisation.
import Foundation
enum APIError: Error {
case mauvaiseURL
case mauvaiseReponse(statut: Int)
case decodage(Error)
case reseau(Error)
}
struct ClientAPI {
static let baseURL = URL(string: "https://jsonplaceholder.typicode.com")!
func fetchPosts() async throws -> [Post] {
let url = Self.baseURL.appending(path: "/posts")
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) else {
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
throw APIError.mauvaiseReponse(statut: code)
}
do {
return try JSONDecoder().decode([Post].self, from: data)
} catch {
throw APIError.decodage(error)
}
}
}
La méthode data(from:) de URLSession retourne un tuple (Data, URLResponse). Le guard valide le statut HTTP — un appel renvoyant 404 ou 500 doit lever une erreur explicite, sinon on tenterait de décoder une page d'erreur HTML comme du JSON. La conversion response as? HTTPURLResponse est sûre pour HTTP/HTTPS ; pour d'autres protocoles (ftp, file), elle pourrait échouer.
Étape 4 — Appeler le client depuis une vue SwiftUI
L'appel se fait dans le modifier .task, qui lance une tâche asynchrone liée au cycle de vie de la vue. Quand la vue disparaît, la tâche est automatiquement annulée — ce qui évite les fuites de requêtes en cours sur un écran qui n'est plus visible.
struct PostsView: View {
@State private var posts: [Post] = []
@State private var enChargement: Bool = false
@State private var erreur: String? = nil
var body: some View {
NavigationStack {
Group {
if enChargement {
ProgressView("Chargement…")
} else if let erreur {
VStack {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
Text(erreur).foregroundStyle(.secondary)
Button("Réessayer") { Task { await charger() } }
}
} else {
List(posts) { post in
VStack(alignment: .leading) {
Text(post.title).font(.headline)
Text(post.body).font(.subheadline).foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Posts")
.task { await charger() }
}
}
private func charger() async {
enChargement = true
erreur = nil
do {
posts = try await ClientAPI().fetchPosts()
} catch let e as APIError {
erreur = description(e)
} catch {
erreur = error.localizedDescription
}
enChargement = false
}
private func description(_ e: APIError) -> String {
switch e {
case .mauvaiseURL: return "URL invalide"
case .mauvaiseReponse(let s): return "Erreur HTTP \(s)"
case .decodage: return "Réponse mal formée"
case .reseau(let err): return err.localizedDescription
}
}
}
Trois états cohabitent : chargement, erreur, succès. La fonction charger() est async et .task { await charger() } la lance dès l'apparition de la vue. Au lancement, le simulateur affiche brièvement le ProgressView puis la liste des 100 posts factices fournis par jsonplaceholder.
Étape 5 — Envoyer un POST avec un corps JSON
Pour soumettre des données, on construit une URLRequest avec la méthode HTTP désirée et le payload encodé. Cette mécanique est plus verbeuse qu'un simple GET mais reste linéaire avec async/await.
struct NouveauPost: Encodable {
let title: String
let body: String
let userId: Int
}
func creerPost(_ p: NouveauPost) async throws -> Post {
let url = Self.baseURL.appending(path: "/posts")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(p)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 201 else {
throw APIError.mauvaiseReponse(statut: (response as? HTTPURLResponse)?.statusCode ?? -1)
}
return try JSONDecoder().decode(Post.self, from: data)
}
La méthode data(for:) accepte une URLRequest au lieu d'une simple URL. Le statut attendu pour POST /posts sur jsonplaceholder est 201 Created ; le corps de la réponse contient le post nouvellement créé avec son id attribué côté serveur. Sur ce service de test, la création n'est pas réellement persistée — il faut consulter la documentation de chaque API réelle pour connaître la sémantique exacte.
Étape 6 — Gérer l'annulation et la concurrence
Une tâche asynchrone Swift peut être annulée par son parent. Cette annulation se propage : URLSession détecte le cancel et interrompt la requête réseau en cours. Pour démontrer le pattern, voici une version qui supporte l'annulation manuelle.
@State private var tacheCourante: Task? = nil
func charger() {
tacheCourante?.cancel()
tacheCourante = Task {
do {
posts = try await ClientAPI().fetchPosts()
} catch is CancellationError {
// Annulation propre, rien à faire
} catch {
erreur = error.localizedDescription
}
}
}
Le Task nouvellement créé remplace l'ancien — si charger() est appelé rapidement plusieurs fois (par exemple sur un swipe-to-refresh agressif), seule la dernière requête est honorée. Le catch is CancellationError intercepte spécifiquement l'annulation sans la confondre avec une erreur réseau.
Étape 7 — Décoder un JSON avec des clés en snake_case
Beaucoup d'APIs réelles utilisent snake_case pour les clés (user_id plutôt que userId). Le JSONDecoder standard convertit automatiquement entre les deux styles via la propriété keyDecodingStrategy.
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
let posts = try decoder.decode([Post].self, from: data)
Avec .convertFromSnakeCase, la clé JSON user_id se mappe automatiquement à la propriété Swift userId. .iso8601 décode les dates au format "2026-05-17T09:00:00Z" en Date Swift sans formatter manuel. Ces deux réglages couvrent 80 % des cas en pratique.
Étape 8 — Tester contre une API réelle
Pour valider un client contre un serveur de staging, il suffit de remplacer Self.baseURL. Pour passer en production sans recompiler, externaliser l'URL dans un Info.plist et lire la valeur via Bundle.main.object(forInfoDictionaryKey:). Pour les apps clientes d'un backend, ce pattern permet de basculer entre staging et production via un xcconfig.
extension Bundle {
var baseAPIURL: URL {
guard let s = object(forInfoDictionaryKey: "API_BASE_URL") as? String,
let url = URL(string: s) else {
fatalError("API_BASE_URL absent dans Info.plist")
}
return url
}
}
Le fatalError en cas de configuration manquante est une décision pragmatique : un binaire mal configuré doit échouer le plus tôt possible, pas envoyer des requêtes à une URL bidon. En production sur un appareil de bêta-testeur, ce crash est mille fois préférable à un comportement silencieusement cassé.
Patterns pour structurer un client API qui grandit
Le client minimaliste de ce tutoriel suffit pour quelques endpoints. Pour une app qui consomme des dizaines de routes, plusieurs patterns aident à maintenir la lisibilité. Le premier consiste à séparer le transport (URLSession, headers, retry) du domaine (les routes spécifiques de l'API). Une couche bas-niveau HTTPClient gère data(for:), l'authentification et la conversion des erreurs HTTP en types Swift ; une couche métier au-dessus appelle HTTPClient et expose des méthodes nommées comme fetchPosts(), updateProfile(_:), deleteOrder(id:). Cette séparation rend chaque couche testable indépendamment et permet de remplacer le transport (par exemple pour un test, ou pour passer en mode offline) sans toucher au métier.
Le second pattern utile est l'introduction d'un type APIEndpoint qui décrit chaque route via une enum ou une struct : méthode HTTP, chemin, paramètres de requête, corps. Le client générique consomme cet endpoint et produit la URLRequest correspondante. Avec ce design, ajouter une nouvelle route revient à ajouter un cas d'enum, et la signature du client reste stable. Pour les apps de taille moyenne, ce niveau d'abstraction est suffisant ; les apps plus grosses ajoutent parfois un système de middlewares (logger, retry, refresh token) inspiré d'Express ou de Koin.
Erreurs fréquentes en réseau Swift
| Erreur | Cause | Solution |
|---|---|---|
| « App Transport Security has blocked a cleartext HTTP connection » | URL en http:// au lieu de https:// |
Passer en HTTPS ; ATS Exception via Info.plist seulement pour développement local |
| « The data couldn't be read because it is missing » | Erreur de décodage JSON sur une clé attendue absente | Marquer la propriété optionnelle dans la struct (let title: String?) |
Crash sur URL(string: …)! |
Caractères spéciaux non encodés | Utiliser URLComponents et addingPercentEncoding(withAllowedCharacters:) |
| « Task was cancelled » dans la console | Vue détruite avant la fin de la requête | Comportement normal de .task ; intercepter CancellationError si besoin |
| Headers d'authentification ignorés | Reset après chaque appel sur URLSession.shared |
Utiliser une URLSessionConfiguration custom et la passer à URLSession(configuration:) |
| Réponse 401 silencieuse | Token expiré non géré | Implémenter une vérification du code statut et un refresh token explicite |
Foire aux questions
URLSession.shared ou instance dédiée ?
URLSession.shared est suffisant pour 90 % des cas. Une instance dédiée est utile quand on veut une configuration spécifique (timeout custom, header par défaut, cache distinct, certificate pinning).
Comment ajouter un header d'authentification ?
Soit sur chaque URLRequest via setValue(_:forHTTPHeaderField:), soit globalement via URLSessionConfiguration.default.httpAdditionalHeaders. La seconde forme est plus propre pour un token utilisé partout.
Pourquoi async/await plutôt que les completion handlers ?
Le code linéaire async/await évite l'imbrication classique en cascade des completion handlers (callback hell). Les erreurs se propagent naturellement via throws. L'annulation est intégrée au système. Le compilateur vérifie statiquement les data races. Les completion handlers restent compatibles mais devraient être réservés aux APIs anciennes qui ne sont pas migrées.
Quelle bibliothèque pour le pinning de certificat ?
URLSession propose URLSessionDelegate avec urlSession(_:didReceive:completionHandler:) pour valider manuellement un certificat. Pour des cas avancés, TrustKit est une bibliothèque open-source maintenue qui simplifie cette configuration.
Faut-il URLSession ou Alamofire ?
URLSession suffit pour la grande majorité des projets — pas de dépendance externe, pleinement intégré au système, support officiel Apple. Alamofire reste utile pour des projets avec interceptors complexes, retry automatique sophistiqué, ou équipe déjà familière de l'API.
Comment télécharger un gros fichier sans saturer la mémoire ?
Utiliser download(from:) au lieu de data(from:). Le résultat est une URL pointant vers un fichier temporaire sur disque, pas un Data en RAM. C'est essentiel pour les fichiers de plusieurs dizaines de Mo.
Tutoriels suivants conseillés
- Tester une app SwiftUI avec Swift Testing et les Xcode Previews — écrire des tests pour le client API.
- Gérer l'état d'une app avec @Observable — structurer le view model qui consomme l'API.