Dans cet article nous allons voir comment émettre un fichier depuis une application mobile développée sous React Native 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 axios.

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 React Native.

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 React Native. 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 React Native.

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 React Native.

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 React Native :

npx react-native init 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.

Avant d’aller plus loin vous devez installer les modules suivants dans le projet :

npm i --save axios@0.27.2 react-native-camera@4.2.1 react-native-vector-icons@9.1.0 urijs@1.19.11

Vous aurez également besoin d’installer comme dépendances de développement des déclarations liées à TypeScript pour certains des modules ci-dessus :

npm i -D @types/react-native-vector-icons @types/urijs

React Native Camera

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

Nous allons déjà créer un composant Camera qui contiendra une définition du module RNCamera et que nous importerons ensuite dans notre fichier App.tsx nouvellement créé. Créez un dossier components à la racine du projet et ajoutez un fichier Camera.tsx qui contient le code suivant :

import React, {forwardRef} from 'react';
import {StyleSheet} from 'react-native';
import {RNCamera} from 'react-native-camera';

const Camera = forwardRef<RNCamera>((props, ref) => {
  return (
    <RNCamera
      ref={ref}
      captureAudio={false}
      style={styles.rnCamera}
      type={RNCamera.Constants.Type.back}
      ratio={'4:3'}
      flashMode={RNCamera.Constants.FlashMode.off}
      androidCameraPermissionOptions={{
        title: 'Permission to use camera',
        message: 'We need your permission to use your camera',
        buttonPositive: 'Ok',
        buttonNegative: 'Cancel',
      }}
    />
  );
});

const styles = StyleSheet.create({
  rnCamera: {
    flex: 1,
    width: '90%',
    height: '90%',
    overflow: 'hidden',
    justifyContent: 'flex-end',
    alignSelf: 'center',
    alignItems: 'center',
  },
});

export default Camera;

Application

Allez ensuite dans le fichier App.tsx à la racine du projet et remplacez en intégralité le code par celui ci-dessous :

import React, {ReactNode, useRef} from 'react';
import {Platform} from 'react-native';
import Camera from './components/Camera';
import Icon from 'react-native-vector-icons/FontAwesome';
import axios from 'axios';
import URI from 'urijs';
import {
  SafeAreaView,
  StyleSheet,
  Text,
  useColorScheme,
  View,
  TouchableOpacity,
  Alert,
} from 'react-native';
import {RNCamera} from 'react-native-camera';
import {Colors} from 'react-native/Libraries/NewAppScreen';

const styles = StyleSheet.create({
  sectionContainer: {
    marginTop: 32,
    paddingHorizontal: 24,
  },
  sectionTitle: {
    fontSize: 24,
    fontWeight: '600',
  },
  sectionDescription: {
    marginTop: 8,
    fontSize: 18,
    fontWeight: '400',
  },
  highlight: {
    fontWeight: '700',
  },
  screen: {
    flex: 1,
    backgroundColor: '#F2F2FC',
  },
  saveArea: {
    backgroundColor: '#62d1bc',
  },
  topBar: {
    height: 50,
    backgroundColor: '#62d1bc',
    alignItems: 'center',
    justifyContent: 'center',
  },
  topBarTitleText: {
    color: '#ffffff',
    fontSize: 20,
  },
  caption: {
    height: 120,
    justifyContent: 'center',
    alignItems: 'center',
  },
  captionTitleText: {
    color: '#121B0D',
    fontSize: 16,
    fontWeight: '600',
  },
  btn: {
    width: 240,
    borderRadius: 4,
    backgroundColor: '#62d1bc',
    paddingHorizontal: 24,
    paddingVertical: 12,
    marginVertical: 8,
  },
  btnText: {
    fontSize: 18,
    color: '#ffffff',
    textAlign: 'center',
  },
  rnCamera: {
    flex: 1,
    width: '94%',
    alignSelf: 'center',
  },
  rmCameraResult: {
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#eeeeee',
  },
  rmCameraResultText: {
    fontSize: 20,
    color: '#62d1bc',
  },
  cameraControl: {
    height: 180,
    justifyContent: 'center',
    alignItems: 'center',
  },
});

const Section: React.FC<{
  children: ReactNode;
  title: string;
}> = ({children, title}) => {
  const isDarkMode = useColorScheme() === 'dark';
  return (
    <View style={styles.sectionContainer}>
      <Text
        style={[
          styles.sectionTitle,
          {
            color: isDarkMode ? Colors.white : Colors.black,
          },
        ]}>
        {title}
      </Text>
      <Text
        style={[
          styles.sectionDescription,
          {
            color: isDarkMode ? Colors.light : Colors.dark,
          },
        ]}>
        {children}
      </Text>
    </View>
  );
};

const App = () => {
  const isDarkMode = useColorScheme() === 'dark';
  const backgroundStyle = {
    backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
  };

  const camera = useRef<RNCamera>(null);

  const takePicture = async (): Promise<void> => {
    const options = {
      quality: 0.5,
      base64: true,
      fixOrientation: true,
      forceUpOrientation: true,
    };

    camera?.current
      ?.takePictureAsync(options)
      .then(response => {
        const formData = new FormData();
        formData.append('picture', {
          name: new URI(response.uri).filename(),
          type: 'image/jpeg',
          uri: Platform.OS !== 'android' ? 'file://' + response.uri : response.uri
        });

        axios.post('http://10.0.2.2:8000/api/saveimg', formData, {
            headers: { 'Content-type': 'multipart/form-data' },
            transformRequest: (data: FormData) => {
              return data;
            }
          })
          .then(() => {
            Alert.alert('Success', 'Image uploaded !');
          });
      })
      .catch((err: unknown) => {
        if (typeof err === 'string') {
          Alert.alert('Error', 'Failed to take picture: ' + err.toUpperCase());
        } else if (err instanceof Error) {
          Alert.alert(
            'Error',
            'Failed to take picture: ' + (err.message || err),
          );
        }
        throw err;
      });
  };

  return (
    <View style={styles.screen}>
      <SafeAreaView style={backgroundStyle}>
        <View style={styles.topBar}>
          <Text style={styles.topBarTitleText}>Deep Fake</Text>
        </View>
      </SafeAreaView>

      <View style={styles.caption}>
        <Section title="Tips">
          Place your face front of the camera and take a screenshot.
        </Section>
      </View>

      <Camera ref={camera} />

      <View style={styles.cameraControl}>
        <TouchableOpacity onPress={takePicture}>
          <Icon name="camera" size={50} color="#62d1bc" />
        </TouchableOpacity>
      </View>
    </View>
  );
};

export default App;

J’ai transformé une partie du code fournit par la template par défaut de React Native 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 à React Native (un peu quand même), par contre je vais m’attarder sur ce qui nous intéresse, à savoir l’appel à notre API.

Axios

RNCamera fournit une méthode asynchrone takePictureAsync 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 de la librairie axios :

const formData = new FormData();
formData.append('picture', {
  name: new URI(response.uri).filename(),
  type: 'image/jpeg',
  uri: Platform.OS !== 'android' ? 'file://' + response.uri : response.uri
});

axios.post('http://10.0.2.2:8000/api/saveimg', formData, {
  headers: {'Content-type': 'multipart/form-data'},
  transformRequest: (data: FormData) => { return data; }
}).then(() => {
  Alert.alert('Success', 'Image uploaded !');
});

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 de préciser dans la configuration des headers de la requête 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 l’objet FormData fournit par React Native (et non le module form-data) pour encapsuler les données à émettre. Celui-ci doit contenir à minima 3 attributs qui sont respectivement :

  • name : le nom du fichier émit.
  • type : le type MIME du fichier émit.
  • uri : l’URI d’accès au fichier émit.

Notez également le nom du champ utilisé pour regrouper les informations du formulaire : picture. C’est bien celui que nous utilisons dans l’API Node.js via l’instance de Multer pour traiter les informations reçues par la requête.

Le dernier point, et probablement le plus important, c’est qu’axios converti par défaut le formulaire en string. Il est donc impératif de forcer axios à envoyer FormData sous le format d’origine sinon il ne sera pas interprété par Multer en arrivant dans l’API. C’est là qu’intervient transformRequest qui permet de surcharger la requête avec les données au bon format.

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 iOS, Apple 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 React Native et Dart (le langage utilisé par Flutter, le concurrent direct de React Native) 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 :

npm run android

VSCode va alors se charger de lancer une instance de Metro qui sert de connecteur entre notre application React Native et l’émulateur Android. 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.

Conclusion

Dans cet article nous avons vu comment transmettre un fichier depuis une application mobile, développée avec le Framework React Native de Facebook, vers une API sous Node.js. Nous avons décortiqué l’utilisation de la librairie axios 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 Flutter :
http://localhost:8080/gerer-upload-fichiers-flutter-nodejs-http-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/saveimg. 10.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


0 commentaire

Laisser un commentaire

Avatar placeholder

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 React Native vers une API Node.…

par Cyrille P. temps de lecture : 9 min
0