1. Créer – ce n’est pas 200 OK
Si vous avez déjà écrit des programmes en console, il est très facile de tomber dans le piège : « l’essentiel est que ça soit créé, et la réponse… bon, on renvoie 200 et un texte quelconque ». Dans le monde HTTP, c’est un peu comme dire au livreur : « J’ai reçu le colis, mais je ne vous donne pas le numéro de suivi – faites-moi confiance sur parole. » Les clients sourient poliment… et commencent à écrire des béquilles pour deviner ce qui s’est passé.
Quand vous faites un POST sur une collection, vous créez une nouvelle ressource qui n’existait pas « hier ». Elle acquiert une nouvelle identité (id) et, surtout, une adresse à laquelle on peut la lire (GET /api/v1/reading-list/{id}). Ce n’est plus simplement « une requête traitée avec succès », c’est un type de succès distinct.
Il est donc important d’apprendre à parler au client dans le langage HTTP, et non pas dans le langage « ma méthode s’est exécutée sans exception ». Le succès en Java et le succès en HTTP sont deux choses différentes. En Java, vous pouvez renvoyer un objet et être satisfait. En HTTP, vous êtes tenu d’assembler un triplet : statut + en-têtes + corps, sinon le contrat devient flou.
Bonne nouvelle : ce n’est pas du « byzantinisme pour le plaisir ». Cela simplifie vraiment la vie de votre futur client – que ce soit Postman, un front-end ou un autre service.
Signification du statut 201 Created
Le statut 201 Created est la façon pour le serveur de dire clairement : « Je n’ai pas seulement traité ta requête, j’ai créé une nouvelle ressource ». Et c’est une information précieuse, car le client peut ensuite se comporter différemment. Par exemple, l’interface utilisateur peut afficher « Créé », ajouter un élément à la liste sans nouvelle requête, ou encore naviguer vers l’écran des détails – tout cela sans avoir à deviner.
Pour fixer les idées, il est utile de comparer les statuts « succès » les plus courants dans le monde CRUD :
| Opération (CRUD normal) | Statut typique | Y a-t-il un body ? | Ce que ça signifie en langage humain |
|---|---|---|---|
| GET /... | 200 OK | généralement oui | « Voici les données que tu as demandées » |
| POST /collection | 201 Created | souvent oui | « J’ai créé une nouvelle ressource ; voici ce que ça donne » |
| PUT/PATCH /resource | 200 OK | souvent oui | « J’ai mis à jour la ressource ; voici son nouvel état » |
| DELETE /resource | 204 No Content | non | « Supprimé. Rien à montrer – sache simplement que tout est OK » |
Dans ce cours, nous n’apprenons pas à « connaître beaucoup de statuts », mais à choisir le statut en fonction du sens de l’opération. Et 201 est le prolongement direct de cette habitude.
Il y a encore un point important. 201 Created va presque toujours de pair avec l’en-tête Location. Formellement, on peut renvoyer 201 sans Location, mais cela revient à donner un nouveau passeport à quelqu’un sans lui dire où le récupérer ensuite. Le client veut voir non seulement « créé », mais aussi « où aller désormais pour cette ressource ».
2. L’en-tête Location : l’adresse de la nouvelle ressource
Location est un en-tête de réponse qui contient l’URI de la ressource créée. Son objectif est simple : donner au client l’adresse concrète à laquelle accéder à l’objet qui vient d’être créé. Notez bien : ce n’est pas une redirection (on ne fait pas de 302), on ne force pas le client à « aller quelque part ». On se contente de dire : « Voici l’adresse ».
Dans notre API, cela se présente de manière tout à fait naturelle :
- on crée un élément avec POST /api/v1/reading-list
- on obtient un id, par exemple 42
- donc l’adresse de l’élément est : /api/v1/reading-list/42
Et c’est cette valeur que l’on place dans Location.
L’erreur la plus fréquente chez les débutants est de mettre Location: /api/v1/reading-list (c’est-à-dire l’adresse de la collection). Cela ne sert à rien : le client sait déjà où il a envoyé son POST. Il a besoin de l’adresse spécifique de l’élément.
Location relative et absolue
Dans le monde idéal, Location est souvent construite de manière absolue, du genre http://localhost:8080/api/v1/reading-list/42. Dans notre API locale d’apprentissage, il est acceptable de renvoyer un chemin relatif /api/v1/reading-list/42. Postman et la plupart des clients le comprennent parfaitement, et pour un projet d’étude c’est même plus simple : nous n’avons pas à deviner le scheme/host/port, les proxies et autres tracas d’adulte.
Si l’on veut être « un peu plus formel », on peut construire un Location absolu via l’en-tête Host, mais je ne ferais pas de cela une mini-épreuve supplémentaire. L’essentiel est le sens : Location doit pointer vers la ressource créée, pas vers la collection.
Un petit extrait de code qui illustre l’idée :
// created est l'objet déjà sauvegardé, qui a reçu un id
long id = created.getId();
// Location doit pointer vers la ressource créée spécifiquement, pas vers la collection
String location = "/api/v1/reading-list/" + id;
// On définit les en-têtes de réponse avant d'appeler sendResponseHeaders(...)
exchange.getResponseHeaders().set("Location", location);
Ici, il est important que id n’apparaisse qu’après que le service a créé l’objet. Autrement dit, Location est une donnée « a posteriori », impossible à construire correctement à l’avance.
3. JSON dans la réponse : Content-Type et DTO
Le statut et Location c’est bien, mais en pratique le client préfère souvent recevoir aussi le corps de la réponse. Pourquoi ? Parce qu’après la création, la ressource se voit attribuer un id, des valeurs par défaut peuvent s’appliquer, le status peut être normalisé, et le client veut voir l’état final « tel que le serveur l’a accepté ».
Deux bonnes habitudes se dégagent ici.
La première habitude — ne jamais renvoyer l’objet domaine directement. Oui, techniquement vous pouvez sérialiser ReadingListItem en JSON et l’envoyer. Mais alors le contrat externe de l’API commence à dépendre de la structure interne du domaine. Aujourd’hui vous ajoutez un champ internalNotes pour vous – demain vous « fuyez » accidentellement ce champ dans l’API. C’est ainsi que commencent les histoires « pourquoi le front-end voit-il ce qu’il ne devrait pas voir ».
La deuxième habitude — définir correctement le Content-Type. Si vous renvoyez du JSON, vous écrivez application/json. Sinon, le client doit deviner : est-ce du JSON ? du texte ? « quels sont ces octets que vous m’avez envoyés, cher serveur ? »
Si vous avez déjà ReadingItemResponse (nous avons conçu le modèle de réponse plus tôt), le mapping est aussi direct qu’une traduction du russe en français :
public ReadingItemResponse toResponse(ReadingListItem item) {
// Le DTO est ce que nous promettons à l'extérieur (contrat API),
// alors que le modèle domaine peut vivre sa propre vie et changer indépendamment
return new ReadingItemResponse(
item.getId(), // l'id apparaît après la création
item.getTitle(), // ce que le client s'attend à voir
item.getAuthor(),
item.getStatus(), // par exemple, le serveur a pu normaliser/attribuer un statut
item.getExternalId(),
item.getComment()
);
}
Et la sérialisation via Jackson reste sobre et prévisible (et c’est un compliment) :
// Jackson fournit directement des bytes, pratique pour HttpExchange (on écrit dans un OutputStream)
byte[] json = objectMapper.writeValueAsBytes(response);
// Content-Type fait partie du contrat : le client ne doit pas deviner le format
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
Notez le charset=utf-8. Pour JSON, c’est une bonne habitude courante. En prime, cela évite la situation où quelqu’un lit soudainement la réponse en windows-1251 et obtient « Привет ».
4. Réponse 201 + Location + JSON dans HttpServer
À présent, nous allons assembler la réponse complète : 201, Location, corps JSON. Il est important de comprendre l’ordre des opérations avec HttpExchange : on définit d’abord les en-têtes, puis on appelle sendResponseHeaders(...), et ensuite seulement on écrit dans le corps de la réponse. Si vous inversez l’ordre, c’est comme dans la vie : « nous avons déjà envoyé la lettre, mais ajoutons encore deux lignes ».
Nous avons déjà created
À ce stade, le flux de création a déjà fait tout le travail : le handler a lu le corps, assemblé CreateReadingItemRequest, le service a sauvegardé l’objet, et nous avons un ReadingListItem created avec un nouvel id. L’important est de ne pas refaire ce chemin, mais de construire tranquillement la réponse à envoyer : Location, en-têtes et corps JSON.
Préparation de Location et du JSON
On construit l’adresse de la nouvelle ressource et on produit les bytes JSON. Détail important : ObjectMapper.writeValueAsBytes(...) renvoie directement des bytes en UTF-8, donc pas besoin de « re‑coder » la chaîne manuellement.
// l'id n'est disponible qu'après la création – donc Location se construit a posteriori
long id = created.getId();
// Location pointe vers la ressource créée
String location = "/api/v1/reading-list/" + id;
// On prépare le corps de la réponse (JSON) sous forme de bytes
byte[] json = objectMapper.writeValueAsBytes(response);
En-têtes et corps de la réponse
Ici commence le « Spring MVC à la main ». Rien d’effrayant, juste de la précision.
// On définit les en-têtes avant d'envoyer la ligne de statut
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.getResponseHeaders().set("Location", location);
// On envoie le statut et la longueur du corps (important : avant d'écrire dans le body)
exchange.sendResponseHeaders(201, json.length);
// On écrit le corps de la réponse et on ferme le flux (pour que le client sache que la réponse est terminée)
try (var os = exchange.getResponseBody()) {
os.write(json);
}
Si l’on considère cet extrait comme un contrat, il se lit parfaitement « du haut vers le bas » : on a déclaré le type de données, on a communiqué l’adresse de la ressource, on a précisé le statut, et on a envoyé le corps. C’est tout.
À la fin, il est conseillé de fermer explicitement exchange (parfois fermer OutputStream suffit, mais dans un code pédagogique, mieux vaut être explicite) :
// On ferme explicitement exchange : cela évite certains « blocages étranges » chez le client
exchange.close();
Oui, une ligne de plus. Mais vous aurez moins de chances de rencontrer un blocage du client qui attend la fin de la réponse.
Helper contre copier-coller
En pratique, vous constaterez très vite que la définition du Content-Type, la sérialisation et l’écriture des bytes se répètent d’un endpoint à l’autre. Il ne faut pas tomber dans « construisons notre propre Spring ». Mais un petit utilitaire qui ne cache pas la mécanique, tout en éliminant le copier-coller, est tout à fait acceptable.
Par exemple, une méthode « envoyer du JSON avec un code et un en-tête supplémentaire » :
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
static void sendCreated(HttpExchange exchange, ObjectMapper mapper, Object body, String location) throws IOException {
// Sérialisation de l'objet en JSON (les bytes sont pratiques à écrire directement dans le body de la réponse)
byte[] json = mapper.writeValueAsBytes(body);
// Content-Type – pour que le client identifie sans ambiguïté le format
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
// Location – adresse de la ressource créée
exchange.getResponseHeaders().set("Location", location);
// On envoie d'abord le statut et la longueur du corps
exchange.sendResponseHeaders(201, json.length);
// Puis on écrit le corps de la réponse
try (var os = exchange.getResponseBody()) {
os.write(json);
}
exchange.close();
}
Notez : ce n’est pas un « framework », c’est juste une fonction courte qui fait exactement ce que vous feriez à la main. Vous voyez toujours ce qui se passe : statut, en-têtes, JSON.
5. Erreurs typiques lors de la création d’une ressource
Erreur n°1 : renvoyer 200 OK « parce que tout s’est bien passé ».
Au niveau « ça marche », oui, ça marche. Mais au niveau du contrat, vous perdez le sens de l’opération. 201 Created porte une sémantique concrète : une nouvelle ressource a été créée. Le client n’a pas à deviner, par la forme du JSON ou un champ « created: true », ce qui s’est exactement produit.
Erreur n°2 : mettre Location sur la collection, pas sur la ressource spécifique.
Si après POST /api/v1/reading-list vous renvoyez Location: /api/v1/reading-list, vous n’apportez aucune information nouvelle. Le client a besoin de l’adresse de l’élément : /api/v1/reading-list/{id}. C’est particulièrement utile lorsque le code client veut « naviguer vers la fiche de l’élément créé » sans faire de calculs supplémentaires.
Erreur n°3 : essayer de construire Location avant la création de l’objet.
Location est impossible sans id. Si vous générez Location « à vue d’œil » ou à partir des données de la requête, vous obtiendrez tôt ou tard une incohérence : le serveur a réellement créé un élément avec un id, mais Location en contient un autre. En conséquence, le client suit Location et obtient un 404, ce qui provoque une crise existentielle : « est-ce que ça a vraiment été créé ? »
Erreur n°4 : oublier Content-Type dans une réponse JSON.
Postman « devine » généralement, mais les clients réels peuvent ne pas deviner. Content-Type: application/json fait partie du contrat. Quand vous faites une API, vous ne devez pas obliger le client à être télépathe.
Erreur n°5 : envoyer 201 mais renvoyer « le mauvais objet ».
Par inattention, on sérialise parfois le DTO de requête ou un objet intermédiaire. La réponse doit refléter l’état créé, y compris l’id généré. Si le corps ne contient pas l’id, le client doit faire un GET supplémentaire ou parser Location pour obtenir l’identifiant. Ce n’est pas toujours mauvais, mais dans notre scénario CRUD d’étude, il est plus simple et plus clair de renvoyer directement la ressource créée.
GO TO FULL VERSION