Gérer l’upload de fichiers sous Flutter vers une API Node.js avec http et Multer

Dans cet article nous allons voir comment émettre un fichier depuis une application mobile développée sous Flutter vers une API Node.js dont la gestion des fichiers uploadés est gérée par le middleware Multer. Les requêtes vers notre API depuis notre application mobile seront effectuées à l’aide de la librairie http.

Cet article part du principe que vous avez déjà un environnement de développement Mobile installé sur votre poste. Dans le cas contraire veuillez vous référer à la documentation de Flutter.

Définition de l’API sous Node.js

Notre API est assez simple dans sa structure et se repose sur express.js pour la mise en place du serveur Web. Elle propose un seul endpoint qui se chargera de sauvegarder sur le serveur le fichier émit depuis notre application mobile via Dart (le langage de développement derrière Flutter). On pourrait se charger de réceptionner et de sauvegarder le buffer de données à l’aide du module file system (fs) de Node.js, mais il existe un autre module pour faciliter l’upload de fichier : Multer.

Si vous êtes novice dans l’utilisation de Node.js, je vous invite à lire les articles suivants qui introduisent cet outil :
– Premiers pas avec Node.js
– Initialiser un projet Node.js sous TypeScript et ESLint
– Initialiser un projet React sous TypeScript avec Webpack

Multer a cependant une contrainte, la requête d’envoie contenant le fichier doit être de la forme multipart/form-data. Mais nous verrons ça un peu plus tard au moment d’expliquer la partie Flutter.

Pour initialiser notre API commencez par exécuter la commande suivante dans le dossier devant contenir votre projet Node.js :

npm init -y

Vous devrez ensuite installer l’ensemble des modules suivants :

npm i --save express@4.17.1 cors@2.8.5 multer@1.4.4

Vous n’êtes pas obligé de préciser le numéro de version de chaque module, mais pour être sûr que notre exemple fonctionne, autant vous indiquer celles utilisées lors de la rédaction de cet article.

Une fois l’installation terminée, il ne vous reste plus qu’à créer un fichier index.js à la racine de votre projet et d’y coller le code suivant :

const express = require("express");const cors = require("cors");
const multer = require("multer");

const app = express();

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, "upload");
  },
  filename: function (req, file, cb) {
    cb(null, file.originalname);
  },
});
const upload = multer({ storage: storage });

app.use(cors());
app.use(express.json());

app.post("/api/saveimg", upload.single("picture"), (req, res, next) => {
  const file = req.file;
  if (!file) {
    const error = new Error("Please upload a file");
    error.httpStatusCode = 400;
    return next(error);
  }

  res.status(200).send(file);
});

app.listen(8000, () => {
  console.log("server listening on port 8000");
});

J’ai pris ici la liberté de configurer un peu plus ma gestion des fichiers sous Multer en utilisant la méthode d’initialisation diskStorage :

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, "upload");
  },
  filename: function (req, file, cb) {
    cb(null, file.originalname);
  },
});

Ca permet de définir le dossier de destination (destination) ainsi que le nom du fichier (filename) stocké sur le serveur. Attention cependant, sous cette forme Multer ne prends pas en charge la création du dossier. Veillez à bien créer le dossier upload (ou tout autre nom que vous voulez utiliser) à la racine de votre projet.

Si vous n’avez pas besoin de configurer plus profondément votre gestion des fichiers, vous pouvez simplement instancier la variable upload de cette manière et omettre la variable storage :

const upload = multer({ dest: 'upload/'});

L’instance de Multer (upload) se passe dans les paramètres de route du endpoint de notre serveur express.js :

app.post("/api/saveimg", upload.single("picture"), (req, res, next) => { ...

Multer se chargera alors de récupérer, depuis la requête cliente, le fichier et de le sauvegarder dans le dossier paramétré. Vous aurez ensuite la possibilité de consulter, dans le callback du endpoint, le contenu du fichier via la propriété req.file. N’hésitez pas à regarder la documentation de Multer pour connaitre toutes les fonctions disponibles. Ici, j’utilise la méthode single pour ne récupérer que le champ picture contenu dans la requête cliente et que nous allons définir de ce pas dans notre application mobile sous Flutter.

Prendre une photo et l’envoyer vers l’API

Initialisation du projet

Première chose à faire initialiser un nouveau projet grâce à la CLI de Flutter :

flutter create takepicturefromcamera

Mon application mobile aura pour simple but de prendre une photo avec la caméra de l’appareil et la transmettre à mon API Node.js.

Plugin Camera

Dans mon exemple j’utilise le plugin Camera pour afficher et générer une prise de vue depuis la caméra du mobile.

Vous devez d’abord déclarer les dépendances qui nous serons nécessaires pour notre application photo. Ouvrez le fichier pubspec.yaml à la racine du projet et ajoutez les lignes suivantes dans l’attribut des dépendances :

dependencies:
  flutter:
    sdk: flutter  

  camera:
  path_provider:
  path:
  http: ^0.13.4

En fonction de l’IDE que vous utilisez vous aurez probablement un pré-chargement automatique des dépendances à la moindre modification du fichier pubspec.yaml. Dans le cas contraire il vous faudra exécuter la ligne de commande suivante à la racine du projet Flutter pour les installer :

dart pub get

Ouvrez ensuite le fichier build.gradle situé dans le dossier android de votre projet et recherchez la ligne suivante :

defaultConfig {
  [...]
  minSdkVersion flutter.minSdkVersion
  [...]
}

Changez la valeur flutter.minSdkVersion par 21. Ca correspond au niveau minimum de l’API Android qui doit être utilisée par l’application mobile et surtout par le plugin Camera. La constante Flutter est actuellement positionnée à 16.

Application

Allez ensuite dans le fichier main.dart dans le dossier lib du projet et remplacez en intégralité le code par celui ci-dessous :

import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';

import 'package:camera/camera.dart';
import 'package:flutter/material.dart';

late List<CameraDescription> cameras;

Future<void> main() async {
  // Ensure that plugin services are initialized so that `availableCameras()`
  // can be called before `runApp()`
  WidgetsFlutterBinding.ensureInitialized();

  // Obtain a list of the available cameras on the device.
  cameras = await availableCameras();

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Startup Name Generator',
      theme: ThemeData(
        appBarTheme: const AppBarTheme(
          backgroundColor: Colors.white,
          foregroundColor: Colors.black,
        ),
      ),
      home: TakePictureScreen(camera: cameras.first),
    );
  }
}

// A screen that allows users to take a picture using a given camera.
class TakePictureScreen extends StatefulWidget {
  const TakePictureScreen({
    Key? key,
    required this.camera,
  }) : super(key: key);

  final CameraDescription camera;

  @override
  TakePictureScreenState createState() => TakePictureScreenState();
}

class TakePictureScreenState extends State<TakePictureScreen> {
  late CameraController _controller;
  late Future<void> _initializeControllerFuture;

  @override
  void initState() {
    super.initState();
    // To display the current output from the Camera,
    // create a CameraController.
    _controller = CameraController(
      // Get a specific camera from the list of available cameras.
      widget.camera,
      // Define the resolution to use.
      ResolutionPreset.medium,
    );

    // Next, initialize the controller. This returns a Future.
    _initializeControllerFuture = _controller.initialize();
  }

  @override
  void dispose() {
    // Dispose of the controller when the widget is disposed.
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: const Text('Deep Fake')),
        // You must wait until the controller is initialized before displaying the
        // camera preview. Use a FutureBuilder to display a loading spinner until the
        // controller has finished initializing.
        body: FutureBuilder<void>(
          future: _initializeControllerFuture,
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.done) {
              // If the Future is complete, display the preview.
              return CameraPreview(_controller);
            } else {
              // Otherwise, display a loading indicator.
              return const Center(child: CircularProgressIndicator());
            }
          },
        ),
        floatingActionButton: FloatingActionButton(
          // Provide an onPressed callback.
          onPressed: () async {
            // Take the Picture in a try / catch block. If anything goes wrong,
            // catch the error.
            try {
              // Ensure that the camera is initialized.
              await _initializeControllerFuture;

              // Attempt to take a picture and get the file `image`
              // where it was saved.
              _controller.takePicture().then((value) async {
                var image = await http.MultipartFile.fromPath(
                    'picture', value.path,
                    contentType: MediaType('image', 'jpeg'));

                var request = http.MultipartRequest(
                    'POST', Uri.parse('http://10.0.2.2:8000/api/saveimg'));

                request.files.add(image);

                request.send().then((res) {
                  // If picture send correctly, log the success to the user.
                  _showDialog(context, 'Success', 'Image uploaded !');
                }).catchError((error) {
                  // If an error occurs, log the error to the user.
                  _showDialog(context, 'Alert', error.toString());
                });
              });
            } catch (err) {
              // If an error occurs, log the error to the console.
              _showDialog(context, 'Alert', err.toString());
            }
          },
          child: const Icon(Icons.camera_alt),
        ),
        bottomSheet: Row(children: const <Widget>[
          Expanded(
            child: Text(
                'Place your face front of the camera and take a screenshot.',
                textAlign: TextAlign.center),
          )
        ]));
  }
}

Future<void> _showDialog(
    BuildContext context, String title, String message) async {
  return showDialog<void>(
    context: context,
    barrierDismissible: false, // user must tap button!
    builder: (BuildContext context) {
      return AlertDialog(
        title: Text(title),
        content: SingleChildScrollView(
          child: ListBody(
            children: <Widget>[
              Text(message),
            ],
          ),
        ),
        actions: <Widget>[
          TextButton(
            child: const Text('Ok'),
            onPressed: () {
              Navigator.of(context).pop();
            },
          ),
        ],
      );
    },
  );
}

J’ai transformé une partie du code fournit par la template par défaut de Flutter pour qu’elle corresponde à mes besoins. Je ne vais pas détailler le code de l’application, cet article n’étant pas dédié spécifiquement à Flutter (un peu quand même), par contre je vais m’attarder sur ce qui nous intéresse, à savoir l’appel à notre API.

http

Camera fournit une méthode asynchrone takePicture qui retourne une promesse dont la réponse contient l’URI d’accès où est stockée la photo prise avec la caméra. C’est également à ce niveau que j’effectue l’appel à mon API à l’aide du plugin http :

var image = await http.MultipartFile.fromPath(
  'picture', value.path,
  contentType: MediaType('image', 'jpeg'));

var request = http.MultipartRequest('POST', Uri.parse('http://10.0.2.2:8000/api/saveimg'));

request.files.add(image);

request.send().then((res) {
  // If picture send correctly, log the success to the user.
  _showDialog(context, 'Success', 'Image uploaded !');
}).catchError((error) {
  // If an error occurs, log the error to the user.
  _showDialog(context, 'Alert', error.toString());
});

Premier point important, Multer ne prends en charge que les requêtes dont le contenu est de type multipart/form-data. Il est donc nécessaire d’utiliser l’objet MultipartRequest fournit par http pour générer une requête dont la configuration des headers contiendra l’attribut Content-type sur multipart/form-data.

L’autre point important est la structure du format des données à envoyer à l’API et surtout l’utilisation de la méthode fromPath de l’objet MultipartFile fournit par http pour encapsuler les données à émettre. Celui-ci contient 4 paramètres, dont 2 optionnels, qui sont respectivement :

  • field : le nom du champ contenant le fichier émit.
  • filePath : l’URI d’accès au fichier émit.
  • fileName : le nom du fichier émit. Optionnel.
  • contentType : le type MIME du fichier émit. Optionnel mais conseillé.

Notez que le nom du champ utilisé (Picture) pour regrouper les informations du formulaire est le même que celui utilisé dans l’API Node.js via l’instance de Multer pour traiter les informations reçues par la requête.

Exécution

C’est un peu bête à dire, mais c’est probablement l’une des étapes les plus compliquée lorsqu’on souhaite tester une application mobile sur son poste. Personnellement, j’utilise Visual Studio Code pour développer mon application, mais comble de malchance, je développe sous Windows. Ce qui élimine déjà la possibilité de tester sous iOSApple ayant verrouillé l’utilisation de XCode, son éditeur mobile, sur son parc de machine.

Reste Android et Android Studio qui permet l’installation de l’émulateur Android sur les postes Windows. Avec l’intégration de Dart et React Native (le concurrent direct de Dart) dans Visual Studio Code, il est alors possible de lancer un émulateur Android depuis la barre de statut de Visual Studio Code, puis de lancer la commande :

flutter run

Vous aurez alors la possibilité de tester localement votre application Mobile, de réaliser des modifications à chaud et d’en voir les effets directement dans l’émulateur.

Il est également possible de déboguer facilement l’application Flutter depuis l’onglet de débogue de VSCode simplement en choisissant une configuration liée à l’émulateur Android utilisé depuis la liste disponible dans l’onglet « Exécuter et Déboguer ».

Conclusion

Dans cet article nous avons vu comment transmettre un fichier depuis une application mobile, développée avec le Framework Flutter de Google, vers une API sous Node.js. Nous avons décortiqué l’utilisation de la librairie http pour l’émission de la requête, et vu de quelle manière elle est interceptée et traitée par le module Multer d’express.js.

Pour les plus curieux retrouvez la même chose mais cette fois avec React Native :
https://developer.gutsfun.com/gerer-upload-fichiers-reactnative-nodejs-axios-multer/

Tips

Petite aparté, vous aurez peut-être remarqué l’URL du endpoint utilisé pour contacter l’API : http://10.0.2.2:8000/api/saveimg10.0.2.2 correspond en réalité à l’adresse local de l’émulateur Android fournit par Android Studio et redirigé ensuite vers le localhost de mon poste.

Notez également que pour faire fonctionner correctement l’application sur Android ou iOS vous aurez probablement besoin de modifier les autorisations d’accès à certains composants comme la caméra. Référez vous toujours à la documentation des modules que vous installez pour connaître les autorisations nécessaires à leur bon fonctionnement.

Références

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

Gérer l’upload de fichiers sous Flutter vers une API Node.js avec h…

par Cyrille Perrot Temps de lecture: 10 min
0