React Native File Upload using GraphQL & Apollo

jdelvx

José Del Valle

Posted on September 3, 2020

React Native File Upload using GraphQL & Apollo

It's been a couple of months since my last article. I've been pretty busy working on KnobsAI and haven't had much time to write.

I thought it would be nice to share how I implemented the file upload feature in KnobsAI so here's a short article on that.

Today I'll be showing how to upload pictures from a React Native app to Digital Ocean Storage, using GraphQL and Apollo.

While the example is pretty simple, it sets the ground for more complex stuff. The pictures will be uploaded to Digital Ocean Storage, which uses the AWS API, but you can apply the same logic to upload them to a different service.

If you are using Digital Ocean Storage, you'll need to:

I used the guide from the second link as a starting point for this feature. It doesn't use GraphQL tho, which is what I introduced in my project and today's guide.

Here's the repo with the source code in case you want to fork it.

Server-Side Architecture

The server side is comprised of three files: the index, the schema, and the storage.

In the index.js file, we define our ApolloServer and the Express app. If you've already worked with GraphQL you may have done this differently as there are many ways to do it. The important thing here is the Storage service that is being passed in the ApolloServer context so every resolver can make use of it.

const express = require('express');
const Storage = require('./storage');
const { ApolloServer } = require('apollo-server-express');
const { typeDefs, resolvers } = require('./schema');

const PORT = process.env.SERVER_PORT || 4000;

const app = express();

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({
    req,
    res,
  }) => ({
    req,
    res,
    Storage
  }),
  playground: {
    endpoint: `http://localhost:${PORT}/graphql`
  },
});

server.applyMiddleware({
  app
});

app.listen(PORT, () => {
  console.log(`Server listening on ${PORT}`);
})

The schema es where we define our mutation resolver that will receive the image object from the React Native app and pass it to the Storage service. As you can see, the Storage service is available via the context parameter because we injected it when setting up the server.

const {
  gql,
  GraphQLUpload
} = require('apollo-server-express');

const uploadSchema = gql`
  type Query {
    _empty: String
  }
  type Mutation {
    uploadImage(
      image: Upload
    ): String
  }
`

module.exports = {
  typeDefs: [uploadSchema],
  resolvers: {
    Upload: GraphQLUpload,
    Mutation: {
      uploadImage: async (root, { image }, {
        Storage
      }) => {
        const folder = `rn-upload/`;
        try {
          const uploadResult = await Storage.upload(image, folder);
          return uploadResult.uri;
        } catch(e) {
          return new Error(e);
        }
      },
    }
  }
};

The Storage service is responsible for communicating with the Digital Ocean Storage via the AWS API. Remember from the guide above, you need to store the access keys to your bucket in a .aws/credentials file.

An important thing to note here. The image property received in the resolver above is being sent using apollo-upload-client and it's an object containing a filename, a mime-type, the encoding, and a Read Stream.

The Read Stream is what we need to pass to the s3.upload function as the Body. It took me some time to figure this out as I was passing the entire file object

const aws = require('aws-sdk');
const { v4: uuid } = require('uuid');
const { extname } = require('path');

// Set S3 endpoint to DigitalOcean Spaces
const spacesEndpoint = new aws.Endpoint('nyc3.digitaloceanspaces.com');
const s3 = new aws.S3({
  endpoint: spacesEndpoint,
  params: {
    ACL: 'public-read',
    Bucket: 'your-bucket-name',
  },
});

async function upload(file, folder){

  if(!file) return null;

  const { createReadStream, filename, mimetype, encoding } = await file;

  try {
    const { Location } = await s3.upload({ 
      Body: createReadStream(),               
      Key: `${folder}${uuid()}${extname(filename)}`,  
      ContentType: mimetype                   
    }).promise();         

    return {
      filename,
      mimetype,
      encoding,
      uri: Location, 
    }; 
  } catch(e) {
    return { error: { msg: 'Error uploading file' }};
  }
}

module.exports = {
  upload,
};

Client-Side Architecture

As for the React Native side, the important thing here is integrating apollo-upload-client into the mix.We need to pass an upload link to our ApolloClient using createUploadLink.

Also, don't forget to put your computer's IP if you're running the app on a simulator/emulator, or whatever IP you're using to run the server app.

import React from 'react';
import { ApolloClient } from '@apollo/client';
import { InMemoryCache } from 'apollo-boost';
import { createUploadLink } from 'apollo-upload-client';
import { ApolloProvider } from '@apollo/react-hooks';
import ImageUploader from './ImageUploader';

// Use your computer's IP address if you're running the app in a simulator/emulator
// Or the IP address of the server you're running the node backend
const IP = '0.0.0.0'
const uri = `http://${IP}:4000/graphql`;

const client = new ApolloClient({
  link: createUploadLink({ uri }),
  cache: new InMemoryCache(),
});

export default function App() {

  return (
    <ApolloProvider client={client}>
      <ImageUploader />
    </ApolloProvider>
  );
}

If you happen to have several links you'd need to use ApolloLink.from as in the following example:

const client = new ApolloClient({
  link: ApolloLink.from([
    errorLink,
    requestLink,
    createUploadLink({ uri }),
  ]),
  cache: new InMemoryCache(),
});

We then have an ImageUploader component, which uses an ImagePicker to let you choose an image from the phone's gallery and then calls the uploadImage mutation. The important thing here is to use the ReactNativeFile constructor from the apollo-upload-client package which will generate the object with the Read Stream we discussed above.

Everything else is pretty much UI stuff like showing a loading spinner while the image is being uploaded and a status message when it fails or succeeds. If it succeeds it will display the URL where the image was uploaded.

import React, { useState, useEffect } from 'react';
import { StyleSheet, Button, View, Image, Text, ActivityIndicator } from 'react-native';
import Constants from 'expo-constants';
import * as ImagePicker from 'expo-image-picker';
import { gql } from 'apollo-boost';
import { useMutation } from '@apollo/react-hooks';
import { ReactNativeFile } from 'apollo-upload-client';
import * as mime from 'react-native-mime-types';

function generateRNFile(uri, name) {
  return uri ? new ReactNativeFile({
    uri,
    type: mime.lookup(uri) || 'image',
    name,
  }) : null;
}

const UPLOAD_IMAGE = gql`
  mutation uploadImage($image: Upload) {
    uploadImage(image: $image)
  }
`;

export default function App() {

  const [image, setImage] = useState(null);
  const [status, setStatus] = useState(null);
  const [uploadImage, { data, loading }] = useMutation(UPLOAD_IMAGE);

  useEffect(() => {
    (async () => {
      if (Constants.platform.ios) {
        const { status } = await ImagePicker.requestCameraRollPermissionsAsync();
        if (status !== 'granted') {
          alert('Sorry, we need camera roll permissions to make this work!');
        }
      }
    })();
  }, []);

  async function pickImage () {
    const result = await ImagePicker.launchImageLibraryAsync({
      allowsEditing: true,
      allowsMultipleSelection: false,
      aspect: [4, 3],
      quality: 1,
    });

    if (!result.cancelled) {
      setImage(result.uri);
    }
  };

  async function onUploadPress() {
    status && setStatus(null);
    const file = generateRNFile(image, `picture-${Date.now()}`);
    try {
      await uploadImage({
        variables: { image: file },
      });
      setStatus('Uploaded')
    } catch (e) {
      setStatus('Error')
    }
  }

  return (
    <View style={styles.container}>
      <Button title="Pick an image from camera roll" onPress={pickImage}/>
      {image && <Image source={{ uri: image }} style={{ width: 200, height: 200 }} />}
      {image && <Button title={ loading ? "Uploading" : "Upload"} onPress={onUploadPress} disabled={loading}/>}
      {
        loading && (
          <ActivityIndicator size="small" style={styles.loading}/>
        )
      }
      <Text style={{ color: status === 'Uploaded' ? 'green' : 'red'}}>{status}</Text>
      {
        status === 'Uploaded' && (
          <Text>URL: {data.uploadImage}</Text>
        )
      }
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  loading: {
    margin: 16,
  }
});

Now, this is a super simple example. You will most likely add more logic to this. Let's say, for example, a feature to let users change their profile picture. You'd need to wait for the Storage Service to give you the picture URL and then you'd modify that user in the database.

Here's how I'd do it:

changeUserPicture: async ( 
  _,
  {
    _id,
    picture
  }, {
    User,
    Storage
  }
) => {

  const user = await User.findOne({ _id }); 

  if(user) {
    try {
      const folder = `users/${user._id}/profile/`;
      const { uri } = await Storage.upload(picture, folder);

      user.picture = uri;
      const updatedUser = await user.save(); 

      return updatedUser;
    } catch(e) {
      console.log(e);
    }
  }

  return user;

},

That's pretty much it for today's article! I hope this was useful to you. Feel free to provide any feedback you like or reach out if you need help.

Once again, here's the repo with the source code in case you want to fork it.

Thanks for reading!

💖 💪 🙅 🚩
jdelvx
José Del Valle

Posted on September 3, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related