Lesson 5/8 10 min

JSON: the data contract

Content-Type on send, Accept on request, and a clean JSON body on both sides. The contract that prevents front/back misunderstandings.

Le bug que tout le monde rencontre une fois

Tu codes le formulaire d'ajout de livre de la bibliothèque. Le front envoie le titre, l'auteur, l'ISBN. Tu cliques. Et côté serveur, ton PHP lit… du vide. Aucune donnée. Pourtant tu les vois partir dans l'onglet réseau du navigateur. Elles sont bien envoyées. Mais le back ne les trouve pas.

Le coupable n'est pas une donnée perdue. C'est un malentendu de format. Le front a parlé une langue, le back en attendait une autre, et personne ne l'a dit à voix haute. JSON n'est pas magique : ce n'est que du texte. Pour que les deux machines se comprennent, il faut un contrat.

Rappel de la leçon 1 : REST n'impose pas JSON, c'est un choix, mais c'est le plus courant. Et rappel du cours HTTP (leçon requête-réponse) : on a déjà croisé les headers. Eh bien le contrat de format tient dans deux d'entre eux, et c'est tout.

  • Content-Type: application/json : je dis « voilà ce que J'ENVOIE, lis-le comme du JSON ».
  • Accept: application/json : je dis « voilà ce que JE VEUX recevoir, réponds-moi en JSON ».

Deux headers. L'un décrit le body que tu envoies, l'autre la réponse que tu réclames. Quand ils sont là, tout le monde se comprend. Quand ils manquent, le silence du back commence.

Côté envoi : le trio obligatoire d'un POST

Tu connais déjà fetch du cours JavaScript. On ne le ré-enseigne pas : on l'applique au contrat. Pour envoyer un livre, un POST JSON demande trois choses, toujours les mêmes :

fetch('https://biblio.fr/api/livres', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ titre: 'Dune', auteur: 'Herbert' })
});

La méthode (POST), le header (Content-Type: application/json), le body transformé en texte (JSON.stringify). Retire l'un des trois et le contrat est rompu. Le plus traître à oublier, c'est le header.

Le piège du $_POST vide : si tu oublies le Content-Type: application/json, ou même si tu le mets mais que tu lis mal côté serveur, PHP ne remplit pas $_POST. Pourquoi ? $_POST ne sait lire qu'un formulaire (application/x-www-form-urlencoded ou multipart/form-data). Un body JSON n'est pas un formulaire : c'est du texte brut. PHP le laisse intact dans le flux d'entrée. Pour le récupérer, tu lis le flux toi-même :

$brut = file_get_contents('php://input');
$data = json_decode($brut, true);
$titre = $data['titre'];

C'est LE pont front/back que personne n'explique. Tant que tu cherches dans $_POST['titre'], tu trouveras toujours du vide. Le JSON arrive par php://input, jamais par $_POST.

Côté réponse : le back signe sa part du contrat

Le serveur doit annoncer ce qu'il renvoie, exactement comme le front a annoncé ce qu'il envoyait. Symétrie parfaite. En PHP, deux lignes suffisent :

header('Content-Type: application/json');
echo json_encode($livre);

Le header() pose l'étiquette « ceci est du JSON » sur la réponse. Le json_encode() transforme ton tableau PHP en texte JSON. Sans le header, le navigateur reçoit du JSON mais croit lire du HTML : ton front peut alors planter au response.json().

Et si le client t'envoie un format que tu ne sais pas lire ? Tu ne devines pas, tu ne plantes pas en silence : tu réponds avec le bon code.

Le 415 Unsupported Media Type : c'est la réponse correcte quand un client envoie un Content-Type que ton API ne traite pas (par exemple text/plain alors que tu n'acceptes que du JSON). Le 415 dit clairement : « le format de ton body, je ne le digère pas ». C'est honnête et précis. Bien plus utile qu'un 400 vague ou, pire, un 500 qui laisse croire à un bug serveur alors que c'est le client qui a mal emballé sa requête.

Le contrat de données : prévisible ou rien

Les deux headers réglés, reste le plus important : la forme du JSON lui-même. Un bon contrat de données, c'est une structure stable et prévisible. Le client doit pouvoir compter dessus sans deviner. Quatre règles tiennent tout :

  • Toujours les mêmes clés. Si livres/12 renvoie auteur, alors livres/87 aussi. Une clé qui apparaît un coup sur deux, c'est un bug côté client garanti.
  • Une seule convention de nommage. camelCase OU snake_case, pas un mélange. Du datePublication à côté de nb_pages, et ton front devient un champ de mines.
  • Les dates en ISO 8601. "2026-06-04T10:30:00Z", jamais "04/06/2026" (ambigu : 4 juin ou avril 6 ?). L'ISO 8601 est trié, comparable et sans ambiguïté de fuseau.
  • null explicite plutôt que clé absente. Un livre sans date d'emprunt renvoie "emprunte_le": null, pas une clé qui disparaît. Le client teste une valeur, il ne devine pas une absence.

Compare. À gauche un livre sale, à droite le même propre :

// SALE : clés qui varient, dates ambiguës, absence floue
{
  "id": 87,
  "Titre": "Dune",
  "date_pub": "01/06/65",
  "nbPages": "412"
  // pas de clé pour l'emprunt : absente ou null ? on devine
}

// PROPRE : stable, une seule convention, ISO 8601, null explicite
{
  "id": 87,
  "titre": "Dune",
  "date_publication": "1965-06-01",
  "nb_pages": 412,
  "emprunte_le": null
}

Le propre se lit sans documentation. Le sale oblige le client à inventer des hypothèses, et chaque hypothèse est une future panne.

Le contrat, signé des deux côtés

Garde cette image : le front pose une étiquette sur ce qu'il envoie, le back pose une étiquette sur ce qu'il renvoie. Au centre, le contrat qu'aucun des deux ne peut ignorer.

Le front à gauche, le back à droite. Une flèche aller va du front au back, étiquetée Content-Type: application/json (voilà ce que j'envoie). Une flèche retour va du back au front, étiquetée Content-Type: application/json (voilà ce que je renvoie). Au centre, un cartouche : le contrat, JSON des deux côtés. Le front fetch + JSON.stringify Le back json_decode / json_encode Le contrat JSON des deux côtés Content-Type: application/json voilà ce que j'envoie Content-Type: application/json voilà ce que je renvoie
Un Content-Type à l'aller, un Content-Type au retour : le contrat est signé des deux côtés.
Prédis avant de lire

Tu vas envoyer deux fois exactement le même body {"titre":"Dune"} à POST /api/livres. Une fois avec Content-Type: text/plain, une fois avec Content-Type: application/json. L'un répond 415, l'autre 201. Pourquoi le même body donne deux résultats opposés ?

Voir la réponse

Parce que l'API juge l'étiquette, pas le contenu. Le body est identique, mais le Content-Type dit à l'API comment le lire. Avec text/plain, l'API entend « ceci est du texte brut » : elle n'a pas de lecteur pour ça, donc 415 Unsupported Media Type, sans même regarder le contenu. Avec application/json, elle entend « ceci est du JSON » : elle le parse, crée le livre, renvoie 201. Le contenu n'a jamais changé ; c'est le contrat de format qui décide.

À toi : signer le contrat avec curl

Un terminal simulé. Tu vas envoyer le même livre deux fois avec deux Content-Type différents pour sentir la différence, puis demander un livre proprement avec Accept. Tape les commandes une à une.

🖥️ Terminal simulé · signer le contrat JSON
$

The bug everyone hits once

You're coding the library's add-book form. The front sends the title, author, ISBN. You click. And on the server side, your PHP reads… nothing. No data. Yet you can see it leave in the browser's network tab. It really is sent. But the back can't find it.

The culprit isn't lost data. It's a format misunderstanding. The front spoke one language, the back expected another, and nobody said it out loud. JSON isn't magic: it's just text. For the two machines to understand each other, you need a contract.

Recall lesson 1: REST doesn't force JSON, it's a choice, but it's the most common one. And recall the HTTP course (request-response lesson): we've already met headers. Well, the format contract fits inside two of them, and that's all.

  • Content-Type: application/json — I say "here's what I'M SENDING, read it as JSON".
  • Accept: application/json — I say "here's what I WANT to receive, answer me in JSON".

Two headers. One describes the body you send, the other the response you ask for. When they're present, everyone understands. When they're missing, the back's silence begins.

On send: the mandatory trio of a POST

You already know fetch from the JavaScript course. We won't re-teach it: we apply it to the contract. To send a book, a JSON POST needs three things, always the same:

fetch('https://biblio.fr/api/books', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: 'Dune', author: 'Herbert' })
});

The method (POST), the header (Content-Type: application/json), the body turned into text (JSON.stringify). Drop any one of the three and the contract breaks. The most treacherous to forget is the header.

The empty $_POST trap: if you forget the Content-Type: application/json, or even set it but read it wrong server-side, PHP does not fill $_POST. Why? $_POST only knows how to read a form (application/x-www-form-urlencoded or multipart/form-data). A JSON body isn't a form: it's raw text. PHP leaves it untouched in the input stream. To grab it, you read the stream yourself:

$raw  = file_get_contents('php://input');
$data = json_decode($raw, true);
$title = $data['title'];

This is THE front/back bridge nobody explains. As long as you look in $_POST['title'], you'll always find nothing. JSON arrives through php://input, never through $_POST.

On reply: the back signs its part of the contract

The server must announce what it returns, exactly as the front announced what it sent. Perfect symmetry. In PHP, two lines are enough:

header('Content-Type: application/json');
echo json_encode($book);

The header() puts the "this is JSON" label on the response. The json_encode() turns your PHP array into JSON text. Without the header, the browser receives JSON but thinks it's reading HTML: your front can then crash at response.json().

And if the client sends you a format you don't know how to read? You don't guess, you don't crash silently: you answer with the right code.

The 415 Unsupported Media Type: it's the correct response when a client sends a Content-Type your API doesn't handle (say text/plain when you only accept JSON). The 415 says clearly: "your body's format, I can't digest it". Honest and precise. Far more useful than a vague 400 or, worse, a 500 that suggests a server bug when it's the client who packaged its request wrong.

The data contract: predictable or nothing

Both headers settled, the most important part remains: the shape of the JSON itself. A good data contract is a stable and predictable structure. The client must be able to count on it without guessing. Four rules hold it all:

  • Always the same keys. If books/12 returns author, then books/87 does too. A key that shows up half the time is a guaranteed client-side bug.
  • One naming convention only. camelCase OR snake_case, not a mix. A publicationDate next to a page_count, and your front becomes a minefield.
  • Dates in ISO 8601. "2026-06-04T10:30:00Z", never "06/04/2026" (ambiguous: June 4th or April 6th?). ISO 8601 is sortable, comparable and free of timezone ambiguity.
  • Explicit null rather than a missing key. A book with no loan date returns "borrowed_at": null, not a key that vanishes. The client tests a value, it doesn't guess an absence.

Compare. On the left a dirty book, on the right the same one clean:

// DIRTY: keys that vary, ambiguous dates, fuzzy absence
{
  "id": 87,
  "Title": "Dune",
  "pub_date": "06/01/65",
  "pageCount": "412"
  // no loan key: missing or null? we guess
}

// CLEAN: stable, one convention, ISO 8601, explicit null
{
  "id": 87,
  "title": "Dune",
  "publication_date": "1965-06-01",
  "page_count": 412,
  "borrowed_at": null
}

The clean one reads without documentation. The dirty one forces the client to invent assumptions, and every assumption is a future outage.

The contract, signed on both sides

Keep this picture: the front puts a label on what it sends, the back puts a label on what it returns. In the middle, the contract neither side can ignore.

The front on the left, the back on the right. An outgoing arrow goes from front to back, labelled Content-Type: application/json (here's what I send). A return arrow goes from back to front, labelled Content-Type: application/json (here's what I return). In the centre, a card: the contract, JSON on both sides. The front fetch + JSON.stringify The back json_decode / json_encode The contract JSON on both sides Content-Type: application/json here's what I send Content-Type: application/json here's what I return
A Content-Type on the way out, a Content-Type on the way back: the contract is signed on both sides.
Predict before reading on

You're about to send the exact same body {"title":"Dune"} twice to POST /api/books. Once with Content-Type: text/plain, once with Content-Type: application/json. One returns 415, the other 201. Why does the same body give two opposite results?

Show the answer

Because the API judges the label, not the content. The body is identical, but the Content-Type tells the API how to read it. With text/plain, the API hears "this is raw text": it has no reader for that, so 415 Unsupported Media Type, without even looking at the content. With application/json, it hears "this is JSON": it parses it, creates the book, returns 201. The content never changed; the format contract decides.

Your turn: sign the contract with curl

A simulated terminal. You'll send the same book twice with two different Content-Types to feel the difference, then request a book cleanly with Accept. Type the commands one by one.

🖥️ Simulated terminal · sign the JSON contract
$

🎯 Pratique

S'entraîner (clique pour ouvrir) :

💬 Ré-explique sans regarder
Ré-explique sans regarder

Explique à un collègue la différence entre Content-Type et Accept dans une requête, et qui pose chacun.

Une bonne explication dit : Content-Type décrit le body que J'ENVOIE (« lis-le comme du JSON »), Accept décrit la réponse que JE VEUX (« réponds-moi en JSON »). Sur un POST, le front pose les deux ; le back relit le Content-Type pour savoir comment parser, et honore l'Accept pour formater sa réponse. Sans Content-Type côté front, le back peut refuser (415) ou lire du vide.
🧠 Rappel libre
Rappel libre

Sans remonter : cite les 4 règles d'un contrat de données JSON propre et prévisible.

1) Toujours les mêmes clés (pas de clé qui apparaît un coup sur deux). 2) Une seule convention de nommage (camelCase OU snake_case, jamais mélangés). 3) Dates en ISO 8601 (2026-06-04T10:30:00Z, trié et sans ambiguïté). 4) null explicite plutôt qu'une clé absente, pour que le client teste une valeur au lieu de deviner une absence.
⚖️ Juge le code de l'IA
Accepter ou rejeter le code de l'IA

Ton front envoie un POST JSON, mais côté PHP tu lis du vide. Tu demandes à l'IA de lire le titre reçu. Elle répond : « Facile, récupère-le avec $_POST['titre'], c'est la façon standard de lire un POST en PHP. » Tu acceptes, ou tu rejettes ?

À rejeter : c'est exactement la cause du vide. $_POST ne se remplit que pour un formulaire (x-www-form-urlencoded ou multipart). Un body JSON est du texte brut : PHP le laisse dans le flux d'entrée, donc $_POST['titre'] reste vide. Le bon code lit le flux : $data = json_decode(file_get_contents('php://input'), true); puis $data['titre']. C'est le pont JSON → PHP que l'IA a oublié.
Dans un POST JSON, à quoi sert le header Content-Type: application/json ?
Un client envoie un body en Content-Type: text/plain à une API qui n'accepte que du JSON. Quel status code renvoyer ?
Quelle réponse JSON respecte le mieux un contrat de données propre et prévisible ?
Ton front envoie un POST JSON, mais côté PHP $_POST['titre'] est vide. Quelle est la cause, et le bon fix ?
Next step

One book is fine. But 12,000? Lesson 6: paginate, filter, sort, and structure errors so clients understand them without guessing.

Lesson 6: Pagination, filters and errors →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement