API HTTP : Formats et encodage
`application/json`, `application/x-www-form-urlencoded`, `multipart/form-data`, query params, quézako ?Dans le précédent article ”Anatomie d’une requête HTTP”, on a vu comment se compose une simple requête GET
. Cette fois, nous allons explorer comment envoyer des données plus complexes et voir les formats d’encodage de donnée les plus communs.
L’envoi de données depuis un client vers un serveur est un point central de toute application web, mais comporte de nombreuses subtilités qui ne sautent pas forcément aux yeux quand on commence à écrire <form>
ou fetch
.
Une même donnée peut être encodée sous plusieurs formats. Par exemple, l’objet JSON { "myString": "Hello, World!", "myNumber": 12345 }
devient ceci en urlencoded : myString=Hello%2C%20World%21&myNumber=12345
1.
Chaque format a ses avantages et ses inconvénients, et ne peuvent pas tous représenter les même types de données. 2
Lorsqu’un client envoie de la donnée à un serveur, il utilise le plus souvent les verbes POST
, PUT
ou PATCH
, selon la sémantique définie entre le client et le serveur (REST, SOAP…). La donnée elle-même est transmise dans le body de la requête, et son format est identifié par le header Content-Type
.
Rien n’interdit un serveur de répondre avec un autre format que celui utilisé par le client : par exemple, une requête peut être faite au format multipart/form-data
, et le serveur peut répondre au format application/json
.
Tour d’horizon des formats les plus courant avec une API HTTP
Nous allons comparer plusieurs formats en envoyant la même payload sous plusieurs formats pour comparer à quoi ça ressemble. Nous utiliserons httpbin.org, une application web conçue pour aider les développeurs à débugger tout ce qui touche aux requêtes HTTP. L’endpoint /post
va renvoyer la donnée telle que le serveur l’a perçue.
Le format application/json
JSON est le format de données le plus courant de nos jours. Il est extrêmement facile à mettre en place, accompagne très facilement les langages type OOP avec la possibilité de représenter des types de données primitif (string, number, boolean, …) ainsi que des objets et tableaux, et est lisible par l’humain.
Il y a cependant certains désavantages : Il n’est pas possible de transmettre un blob binaire tel-quel en JSON (il faut d’abord l’encoder, par exemple dans une string au format base64). Si l’on en a besoin, il faut mettre en place tout un système de schemas, de validations et de versionnage des schémas via des outils et des librairies externe. 3
Certains langages avec un typage fort ont plus de mal à utiliser le format JSON, et doivent passer par une étape de parse/validate afin de s’assurer que l’objet JSON a la forme attendue.4
POST /post HTTP/1.1
Host: httpbin.org
Content-Length: 752
Content-Type: application/json
{
"myString": "Hello, World!",
"myNumber": 12345,
"myBool": true,
"myNull": null,
"myArray": [ 1, 2, 3 ]
}
5Et la réponse du serveur :
{
...
"json": {
"myArray": [ 1, 2, 3 ],
"myBool": true,
"myNull": null,
"myNumber": 12345,
"myString": "Hello, World!",
},
...
}
On peut voir que le serveur répond précisément ce qu’on lui a envoyé : rien n’est perdu (sauf l’ordre des entrées, qui n’est pas garantie dans un objet JSON).
Le format multipart/form-data
Le format multipart/form-data
est principalement utilisé quand un développeur veut envoyer des fichiers depuis le navigateur vers le serveur.
Ce format est beaucoup plus verbeux, mais a un avantage de taille : il peut encoder de la donnée binaire, et donc permet très facilement d’envoyer un fichier tel-quel, sans avoir à l’encoder (par exemple en base646) pour pouvoir l’envoyer dans un objet JSON.
Dans cette requête, j’envoie la même donnée qu’au dessus, avec en plus un fichier image, myFile
.
POST /post HTTP/1.1
Host: httpbin.org
Content-Length: 13249
Content-Type: multipart/form-data; boundary=----0949236922024223924976205735
------0949236922024223924976205735
Content-Disposition: form-data; name="myString"
Hello, World!
------0949236922024223924976205735
Content-Disposition: form-data; name="myNumber"
12345
------0949236922024223924976205735
Content-Disposition: form-data; name="myBool"
true
------0949236922024223924976205735
Content-Disposition: form-data; name="myNull"
null
------0949236922024223924976205735
Content-Disposition: form-data; name="myArray"
1
------0949236922024223924976205735
Content-Disposition: form-data; name="myArray"
2
------0949236922024223924976205735
Content-Disposition: form-data; name="myArray"
3
------0949236922024223924976205735
Content-Disposition: form-data; name="myFile"; filename="blob"
Content-Type: image/webp
(binary data not shown)
------0949236922024223924976205735--
On peut voir que pour ce Content-Type, chaque champ est séparé par une chaîne de caractères aléatoire : la boundary
, définie dans le header Content-Type
.7
Ce format n’a aussi pas les même correspondances avec des objets et tableaux traditionnels : le tableau myArray
se retrouve encodé en 4 sous-champ séparés, mais avec le même name
. C’est au serveur de faire le mapping avec un objet : par exemple, en JS, la classe FormData
offre une méthode .getAll(name)
pour récupérer toutes les valeurs avec un nom donné.
Chaque champ contient aussi des sous-headers : name
vient du nom de l’input dans l’élément <form>
. Le dernier champ, qui contient un fichier a, en plus, un filename
et un Content-Type
, déterminé par le navigateur automatiquement.
La réponse du serveur :
{
...
"files": {
"myFile": "827370701941800876966808680568810000160001490014600657..."
},
"form": {
"myArray": [ "1", "2", "3" ],
"myBool": "true",
"myNull": "null",
"myNumber": "12345",
"myString": "Hello, World!"
},
...
}
On peut voir que ce format ne différencie pas le type de la donnée : tout est décodé en string
, et le développeur doit parser chaque champ correctement dans sa backend.
httpbin est un outil pour débugger les requêtes HTTP, et se permet donc quelques aménagements pour le confort du développeur. Ici, on peut voir notre fichier encodé en string, mais ce n’est qu’à titre de visualisation : au niveau de la requête HTTP, le fichier est en binaire tel quel.
Quand on utilise ce format, on préfèrera faire en sorte que la donnée à envoyer reste simple : soit parce que notre formulaire est simple, soit il est plus compliqué mais géré via JS et l’application sera architecturée pour faire plusieurs requêtes sous différents formats (par exemple JSON pour la donnée, form-data pour les fichiers).
Le format application/x-www-form-urlencoded
Le form-urlencoded est le format utilisé par défaut dans un élément <form>
. Hormis ce cas là, le format est relativement peu utilisé de nos jours, en dehors des applications legacy : on lui préfère le JSON. C’était aussi le format par défaut dans jQuery ($.
ajax
(...)
, $.post(...)
)
POST /post HTTP/1.1
Host: httpbin.org
Content-Length: 105
Content-Type: application/x-www-form-urlencoded
myString=Hello%2C+World%21&myNumber=12345&myBool=true&myNull=&myArray=1&myArray=2&myArray=3
Sur la requête, on peut à nouveau remarquer ceci :
- Il n y a aucun moyen d’encoder
null
- Il n y a pas de différence entre les strings, bools et numbers
La réponse du serveur confirme nos observations :
{
...
"form": {
"myArray": [ "1", "2", "3" ],
"myBool": "true",
"myNull": "",
"myNumber": "12345",
"myString": "Hello, world!"
},
...
}
Notre null
est devenu une chaîne de caractères vide ""
, et notre bool et nombre sont elles aussi devenues des chaînes de caractères. Quand on utilise ce format, le développeur doit connaître le types des champs et les parser de manière appropriée.
La façon dont sont encodés les tableaux et objets imbriqués est sujet à interprétation entre les implémentations ! Chaque langage y met aussi du sien : par exemple, en js, null
serait encodé en "null"
(une chaîne de caractères) plutôt que ""
(une chaîne de caractères vide).
Vous pouvez trouver plus de détails sur ce format dans l’article ”Les pièges du format urlencoded“.
Les Query Parameters
Il y a un détail qui peut porter à confusion dans le format application/x-www-form-urlencoded
: c’est une requête POST, mais le format contient le terme urlencoded
.
En effet, comme son nom l’indique, le format urlencoded peut se retrouver dans l’URL ! Les Query Parameters sont en fait à l’origine de ce format.8
On peut envoyer exactement la même payload qu’au dessus, mais en GET et via les Query Params :
GET https://httpbin.org/get?myString=Hello%2C+World%21&myNumber=12345&myBool=true&myNull= HTTP/1.1
Host: httpbin.org
On retrouve dans la réponse exactement la même payload, mais cette fois dans l’objet args
plutôt que dans form
:
{
...
"args": {
"myBool": "true",
"myNull": "",
"myNumber": "12345",
"myString": "Hello, world!"
},
...
}
Pour illustrer 9, on peut même envoyer une requête avec des Query Params et un Body sur une requête POST.
POST /post?myString=Hello%2C+query+params%21&myNumber=12345 HTTP/1.1
Content-Length: 48
Content-Type: application/x-www-form-urlencoded
Host: httpbin.org
myString=Hello%2C+request+body%21&myNumber=12345
On peut voir que l’url (première ligne) et le body (dernière ligne) suivent le même format. Le serveur les interprète de la même manière mais dans deux objets différents :
{
...
"args": {
"myNumber": "12345",
"myString": "Hello, query params!"
},
"form": {
"myNumber": "12345",
"myString": "Hello, request body!"
},
...
}
Ce format est donc versatile pour des données simples et offre plus de flexibilité aux requêtes GET
, mais devient très vite limitant des qu’on veut représenter de la donnée complexe.
Pour résumer : De nos jours, on utilise en général le Content-Type application/json
dans les nouveaux projets parce que c’est le plus simple et disponible dans tous les langages. application/x-www-form-urlencoded
est utilisé dans les systèmes plus ancien (par exemple d’anciens sites qui font des requêtes via jQuery plutôt que fetch
ou axios
, des vieux serveurs qui n’acceptent pas le JSON…). Et multipart/form-data
est principalement utilisé quand on veut envoyer des fichiers depuis le client, ou quand on utilise un élément <form>
. Pour des cas plus particuliers, on peut plus rarement retrouver des formats comme protobuf
.
À travers cet article, nous avons vu un exemple des principaux encodages utilisés par les développeurs web, ainsi que des requêtes HTTP au format texte simple. J’espère que cette approche aura permis de démystifier un peu le concept d’encodage et de formats, ainsi que des requêtes HTTP en général.
Bonus : définir une API HTTP avec OpenAPI
OpenAPI nous permet (entre autres) définir le schéma de données indépendamment du format qu’on utilise.
Par exemple, voici le schéma (nommé ici MonSchema
) de la requête au dessus :
components:
schemas:
MonSchema:
type: object
properties:
myNumber:
type: integer
myString:
type: string
On peut maintenant faire référence à ce schéma via "#/components/schemas/MonSchema"
. Le schéma suivant défini l’endoint /hello-get
qui accepte des avec notre schéma en Query Parameters, et l’endpoint /hello-post
qui accepte des requêtes avec un Body soit en application/json
, soit en application/x-www-form-urlencoded
:
/hello-get:
get:
parameters:
- in: query
schema:
$ref: '#/components/schemas/MonSchema'
/hello-post:
post:
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/MonSchema'
application/json:
schema:
$ref: '#/components/schemas/MonSchema'
Ce schéma OpenAPI indique que chaque endpoint accepte exactement la même payload, mais sous trois formats différent !
NOTE : Ce schéma est à titre d’exemple - en pratique, un serveur applicatif qui accepte plusieurs Content-Types est très rare. Une backend est développée avec la connaissance de la stack que la frontend utilise (et vis-versa), et il n y a que peu de raisons pour que la (ou les) frontends utilisent un format d’échange de données différent.
Footnotes
-
Les caractères spéciaux sont transformés en percent-encoding : un espace devient
%20
, une virgule devient%2C
, etc. Les champs sont séparés par des &. ↩ -
Un serveur s’attend en général à recevoir un format de données en particulier. Une requête bien faite incluera le header
Content-Type
pour indiquer au serveur le type de données qu’on envoie - et un serveur bien fait validera qu’il sait lire ce format avant d’essayer de le parser. ↩ -
Bien que rarement utilisé pour des requêtes HTTP, le format protobuf intègre ces concepts nativement. Les schémas sont versionnés et rétrocompatible, et le type des champs est connu à l’avance. ↩
-
Des outils comme quicktype.io permettent d’automatiser la génération de validateurs. ↩
-
Pour rappel, une requête HTTP est composé en 3 parties : l’en-tête (ici le verbe
POST
, l’url/post
, et la versionHTTP/1.1
), les Headers (Content-Length
,Content-Type
, …), un saut de ligne, puis le body de la requête (ici, un objet json). ↩ -
Encoder un blob binaire en base64 augmenterait sa taille d’environ 33%. ↩
-
Le navigateur s’assure que cette chaîne de caractères n’apparaît pas dans le contenu de la requête : c’est comme ça qu’on peut envoyer plusieurs fichiers binaires à la fois, sans nécessiter d’encodage. ↩
-
Ils sont parfois appellés “Search Params” parce qu’ils sont souvent utilisés pour passer les paramètres d’une recherche sur un site. ↩
-
Pour être honnête, c’est pour cet exemple en particulier que j’écris le contenu des requêtes HTTP depuis le début de l’article ! ↩