Transfert asynchrone de fichiers avec XMLHttpRequest et Flask
Par Benjamin Delmée le 08.11.2019
Nous supposons intuitivement que les modifications apportées à notre profil utilisateur sont automatiquement enregistrées et qu'il n'est plus besoin de cliquer sur un bouton pour qu'elles soient prises en compte. Voyons comment les interfaces modernes utilisent les requêtes asynchrones pour parvenir à ce résultat, plus particulièrement pour le transfert de fichiers.
Les objets XMLHttpRequest et FormData
Commençons par illustrer le fonctionnement d'un formulaire écrit exclusivement en HTML.
<form action="/upload" method="POST" enctype="multipart/form-data">
<input type="text" name="desc">
<input type="file" name="file">
<input type="submit" value="Go!">
</form>
Ici tout est géré par le navigateur. Lorsque l'utilisateur appuie sur le bouton de soumission Go!
, le navigateur va :
- transformer les données en un ensemble de paires clé/valeur
- envoyer les données de manière synchrone au serveur en même temps qu'il redirige l'utilisateur vers la page
/upload
Cette manière de transmettre des données au serveur est simple et fonctionnelle mais l'expérience utilisateur est assez médiocre, particulièrement à cause du changement de page. Les interfaces modernes ont abandonné ce système et gardent les utilisateurs sur la même page en envoyant les données de manière asynchrone afin de ne pas bloquer l'interface.
Pour cela, on utilise côté navigateur les objets javascript FormData
et XMLHttpRequest
qui servent respectivement à construire des formulaires et à envoyer des requêtes de manière asynchrone. Ils permettent de construire des interfaces de transfert de données plus complexes que celles réalisables avec du code HTML seul.
Le code ci-dessous utilise FormData
et XMLHttpRequest
pour construire le formulaire et l'envoyer de manière asynchrone, sans forcer l'utilisateur à quitter la page :
<form id="form">
<input type="text" name="desc">
<input type="file" name="file">
<input type="submit" value="Go!">
</form>
<div id="result"></div>
<script>
document.querySelector('#form').addEventListener('submit', (event) => {
// do not submit the form with the default mecanism
event.preventDefault();
// pack data to be sent in a FormData object
var formData = new FormData(event.target)
// send data through a XHR request
var req = new XMLHttpRequest();
req.addEventListener('load', (res) => { document.querySelector('#result').textContent = req.response });
req.addEventListener('error', (res) => { document.querySelector('#result').textContent = req.response });
req.open('POST', '/upload');
req.send(formData);
});
</script>
Quelques remarques :
- Les valeurs des attributs
action
etmethode
ne sont plus renseignées dans le formulaire mais lors de l'appel à la méthodeXMLHttpRequest.open
- L'attribut
enctype
a disparu du formulaire car il n'est plus nécessaire. La valeur du Content-Type est déterminée automatiquement lors de l'appel à la méthodeXMLHttpRequest.send
. Dans cet exemple il sera automatiquement fixé àmultipart/form-data
car le formulaire contient un champ de typefile
Cette méthode permet de soumettre facilement un formulaire de manière asynchrone et elle a l'avantage de garder fonctionnel le mécanisme de validation des champs : si l'utilisateur tente de soumettre un champ marqué required
sans l'avoir rempli alors le navigateur bloquera l'envoi du formulaire.
Mais on peut aller plus loin et choisir de construire le formulaire soi-même:
<input type="text" name="desc" id="desc">
<input type="file" name="file" id="file">
<input type="submit" value="Go!" id="go">
<div id="result"></div>
<script>
document.querySelector('#go').addEventListener('click', (event) => {
// pack data to be sent in a FormData object
var formData = new FormData()
formData.append('desc', document.querySelector('#desc').value);
formData.append('file', document.querySelector('#file').files[0]);
// send data through a XHR request
var req = new XMLHttpRequest();
req.addEventListener('load', (res) => { document.querySelector('#result').textContent = req.response });
req.addEventListener('error', (res) => { document.querySelector('#result').textContent = req.response });
req.open('POST', '/upload');
req.send(formData);
});
</script>
Quelques remarques :
- La balise
form
a disparu car elle n'est plus nécessaire. Par conséquent il n'est plus nécessaire de bloquer la soumission par défaut du formulaire avec l'instructionevent.preventDefault()
- Le formulaire est construit manuellement en ajoutant les champs un par un à l'aide de la méthode
FormData.append
- L'instruction
document.querySelector('#file').files
retourne un objet de typeFileList
qui permet d'accéder aux fichiers du champfile
(il peut y avoir plusieurs fichiers dans le cas où l'attributmultiple
est mentionné dans la déclaration de l'input).
Cette méthode permet d'avoir davantage de contrôle sur les données envoyées mais attention, en contrepartie, la validation des champs par le navigateur ne fonctionne pas avec cette méthode.
Réception des fichiers avec Flask
Le code Flask ci-dessous permet de gérer la réception du formulaire rédigé dans la première partie de cet article. Il décrit un unique endpoint /upload
requêtable via la méthode POST
et qui affiche dans la console les noms et les valeurs des champs du formulaire.
from flask import Flask, request
app = Flask(__name__)
@app.route('/upload', methods=['POST'])
def upload():
# iterate over the fields
for field_name, field_value in request.form.items(multi=True):
print('Field: {} Value: {}'.format(field_name, field_value))
# iterate over the files
for field_name, field_file in request.files.items(multi=True):
if field_file.filename == '':
print('File not submitted')
else:
print('Field: {} File: {}'.format(field_name, field_file.filename))
return 'OK'
Une fois le formulaire soumis par l'utilisateur, les données réceptionnées sont stockées par Flask dans les variables request.form
et request.files
. Ces variables sont de type ImmutableMultiDict
, qui correspond à un dictionnaire immutable possédant une ou plusieurs valeurs par clé.
Pourquoi avoir besoin de plusieurs valeurs par clé ? Car plusieurs <input>
peuvent avoir le même attribut name
:
<form>
<input type="file" name="myfile">
<input type="file" name="myfile">
<input type="file" name="myfile">
</form>
Même si l'usage veut que chaque <input>
possède un attribut name
unique de manière à pouvoir l'identifier facilement côté serveur, rien ne l'oblige techniquement. C'est notamment le cas lorsqu'on ajoute dynamiquement des champs en javascript au fur et à mesure de la saisie de l'utilisateur, il n'est pas toujours nécessaire de les nommer de manière unique.
On peut itérer sur un objet ImmutableMultiDict
avec la méthode .items(multi=True)
. Si le paramètre multi=True
n'est pas spécifié, uniquement la première valeur de chaque clé sera itérée. Dans notre formulaire cela ne change rien car chaque champ possède un attribut name
unique et par conséquent chaque clé possède une unique valeur.
Dans le cas où l'on veut enregistrer les fichiers sur le disque tout en gardant leur nom d'origine, il est nécessaire de s'assurer que ce nom n'ait pas été forgé par un attaquant. On utilise pour cela la fonction secure_filename
du module werkzeug.utils
livrée avec Flask :
import os
from flask import Flask, request
from werkzeug.utils import secure_filename
app = Flask(__name__)
@app.route('/upload', methods=['POST'])
def upload():
# iterate over the files
for field_name, field_file in request.files.items(multi=True):
if field_file.filename != '':
filename = secure_filename(field_file.filename)
field_file.save(os.path.join('/upload/directory', filename))
return 'OK'
À retenir
- Côté client, les objets
XMLHttpRequest
etFormData
peuvent être utilisés pour envoyer des requêtes asynchrones - Côté serveur, Flask gère automatiquement les formulaires et met à disposition les données dans les variables
request.form
etrequest.files