Transfert asynchrone de fichiers avec XMLHttpRequest et Flask

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 et methode ne sont plus renseignées dans le formulaire mais lors de l'appel à la méthode XMLHttpRequest.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éthode XMLHttpRequest.send. Dans cet exemple il sera automatiquement fixé à multipart/form-data car le formulaire contient un champ de type file

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'instruction event.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 type FileList qui permet d'accéder aux fichiers du champ file (il peut y avoir plusieurs fichiers dans le cas où l'attribut multiple 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 et FormData 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 et request.files