Skip to main content

Image management via GraphQL

10 min read

Older Article

This article was published 7 years ago. Some information may be outdated or no longer applicable.

We’re going to look at managing images through GraphQL. The application we’ll build uses GraphQL queries and mutations to display (and update) a user’s photo.

If you’re new to GraphQL, check out this introductory article first.

Want to go deeper with GraphQL? Have a look at the Practical Guide to GraphQL - Become a GraphQL Ninja course.

GraphQL vs REST API

GraphQL is a query language for APIs. You ask for exactly the data you need. REST APIs work differently: they expose endpoints, each returning a fixed data structure. The client then picks through whatever comes back.

With GraphQL, the application declares what it wants. That’s it.

REST and GraphQL aren’t competitors. They complement each other (GraphQL can wrap an existing REST API, for instance). Both have valid use cases, and they sit happily together in the same project.

Queries and Mutation

GraphQL is primarily about fetching data. But sometimes you need to send data through a form. That’s where mutations come in. Mutations create (or update) data, and if a mutation returns an object, you can tell GraphQL which fields to hand back. That lets you check the new state of the object right after the update.

A mutation can also return a Boolean, in which case there are no fields to query. You just get true or false.

A typical mutation looks like this:

makeUser(id: Int!, name: String!): User!

This creates a user via a mutation called ‘makeUser’. It requires an id and a name as parameters (both are required). It returns a custom type User (We need to define how the User type looks like as well)

type User {
  id: ID!
  name: String!
  photo: String
}

The photo field returns a String and isn’t required, so there’s no point adding it to the mutation parameters. But it makes perfect sense to have a separate mutation for the image upload. Mutations are for creating data, and file uploads fit that pattern:

uploadImage(filename: String!, id: Int!): String!

Two parameters here. When uploading a file, we need a filename and an id. The id identifies which user gets this image as their profile photo.

User model

Let’s step back and look at the user model:

{
  id: 1,
  name: 'Tamas',
  cars: [1, 2],
  photo: null
}, { // ...

Users have id, name, cars and photo properties. The photo property starts as null.

The cars property references another model. (Left out here for brevity.)

Express and Pug

To display data from the GraphQL instance, we’ll wire up a basic Express app with Pug for templating. Here are the relevant bits:

// code snippet - app.js
const routes = require('./routes');
app.get('/user/:id', routes.userinfo);

// code snippet - routes.js
const userinfo = async (req, res) => {
  const { id } = req.params;
  if (id) {
    const query = `{
      user(id: ${id}) {
        id
        name
        photo
        car {
          id
          make
          model
          colour
        }
      }
    }`;
    const response = await fetchGraphQL(query);
    return res.render('user', {
      data: response.data.user,
    });
  }
  return res.status(400).send('Please provide an ID');
};

The Pug template looks like this:

h3= data.name
if !data.photo
  p
    img(src='https://res.cloudinary.com/tamas-demo/image/upload/w_100,h_100,c_thumb,g_face,r_20/avatar.png')
if data.photo
  p
    img(src=data.photo)
if data.car.length === 0
  p This user has no cars.
if data.car
  ul
    for car in data.car
      li #{car.make} #{car.model} (#{car.colour})

p Upload a profile photo:
form(method='post', action='/upload', enctype='multipart/form-data')
  input(type='file', name='file')
  input(type='hidden', value=data.id name='id')
  input(type='submit', value='Upload')

p
  a(href='/') Go back

Two things to notice. First, when photo isn’t set, a placeholder avatar loads from Cloudinary. Second, there’s a form for uploading files. We’ll get to the /upload endpoint shortly, but first, a quick word on image management.

Image management

We’re using Cloudinary here, a cloud-based media management service. No local asset management needed. Cloudinary handles hosting, optimisation, and transformations on both images and videos.

The upload process has two phases:

  1. Upload an image from the frontend to Node.js
  2. Forward the image from Node.js to Cloudinary for storage

We’ll also look at retrieving the image using a custom GraphQL scalar.

Uploading an image

Multiple npm packages handle file uploads in Node.js. We’ll use multer. After installing it with npm i multer, configure it like so:

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

Apollo Server also has a file uploader worth checking out.

The code above stores files in the uploads folder and preserves the original filename (without that second function, you’d get a random filename).

Multer acts as middleware, so we need to attach it to a route:

app.post(
  '/upload',
  upload.fields([{ name: 'file' }, { name: 'id' }]),
  routes.upload
);

Notice the two fields. The upload form sends both a file and the id of the current user. The mutation needs that ID too.

In a real application, file upload would likely happen behind authentication. You’d already know the user’s identity, so no hidden field needed. The approach here is for demonstration purposes.

File upload and mutation

Beyond uploading the file to the cloud, we need to run a mutation that updates the user object with the final Cloudinary URL.

Here’s the code handling both at once:

// execute GraphQL queries and mutations
async function fetchGraphQL(query, variables = {}) {
  const response = await fetch('http://localhost:3000/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      query,
      variables,
    }),
  });
  return response.json();
}

// handle the file upload, as an Express route
const upload = async (req, res) => {
  const id = +req.body.id;
  const uploadedFile = req.files.file[0];
  const filename = uploadedFile.filename;
  const mutation = `
  mutation($filename: String!, $id: Int!) {
    uploadImage(filename: $filename, id: $id)
  }
  `;
  await fetchGraphQL(mutation, { filename, id });
  return res.redirect(req.get('referer'));
};

The first function takes a query (or mutation) and an optional variables object, then fires an HTTP POST to the GraphQL processor.

The second function, upload, handles the Express route. It triggers the mutation and refreshes the page via res.redirect(req.get('referer'));.

The mutation uses variables, which is GraphQL’s way of handling dynamic values. This beats jamming dynamic arguments into the query string directly (otherwise the client has to manipulate that string at runtime).

Here’s how the mutation works:

  1. Define a mutation that accepts two variables, $filename and $id, each with their datatype.
  2. Pass those variables’ values into the actual mutation by reference ($filename and $id) and assign them to the right fields (e.g. filename: $filename).

Variables work with queries too, not just mutations. Imagine a cookbook with recipes. Querying for onlyVegetarian(vegetarian: $vegetarian) { recipe(vegetarian: $vegetarian} { prepTime, ingredients } } lets you flip a Boolean to show only vegetarian dishes.

The mutation itself

We’ve got the upload working and the file stored locally, but we still need the mutation code. It’ll update the user object and push the image to Cloudinary.

We could have uploaded directly to Cloudinary, but showing it step by step makes the process clearer.

Install the Cloudinary Node.js SDK (npm i cloudinary) and configure it:

const cloudinary = require('cloudinary');
cloudinary.config({
  cloud_name: process.env.CLOUD_NAME,
  api_key: process.env.API_KEY,
  api_secret: process.env.API_SECRET,
});

Now the actual mutation:

uploadImage: async (parent, { id, filename }, { models }) => {
  const path = require('path');
  const mainDir = path.dirname(require.main.filename);
  filename = `${mainDir}/uploads/${filename}`;

  try {
    const photo = await cloudinary.v2.uploader.upload(filename, {
      use_filename: true,
      unique: false,
    });

    const user = models.users[id - 1];
    user.photo = `${photo.public_id}.${photo.format}`;
    return `${photo.public_id}.${photo.format}`;
  } catch (error) {
    throw new Error(error);
  }
};

This grabs the file from the uploads folder and sends it to Cloudinary. Then it updates the user object with the filename and extension. Before the mutation:

{
  id: 1,
  name: 'Tamas',
  cars: [1, 2],
  photo: null
}, { // ...

After the mutation:

{
  id: 1,
  name: 'Tamas',
  cars: [1, 2],
  photo: 'some-photo.jpg'
}, { // ...

Displaying the image

With a photo in place, we display it through a custom resolver:

User: {
  photo: (parent, { options }) => {
    let url = cloudinary.url(parent.photo);
    return url;
  }
},

This takes the photo property from the user object and feeds it to cloudinary.url(). That method spits out a full Cloudinary URL: https://res.cloudinary.com/account-name/image/upload/filename.jpg.

The application works at this point. Two screenshots of the same user profile, before and after upload:

The upload works, but there’s an obvious problem. The image is huge. We want a thumbnail or profile image. It should show the user’s face and be much smaller (both in pixel dimensions and file size).

Throwing CSS at it wouldn’t shrink the file size. The browser still downloads the whole thing and chews through bandwidth.

Good news: we’ve already uploaded to Cloudinary, so we can serve from Cloudinary too. We saw how to generate a modified URL via a custom resolver. What if we extended that resolver to accept “options”? Options would be parameters specified in the query that modify the final Cloudinary URL.

But how do you pass an “options” object to the resolver? We’d want multiple options: image width, a crop setting for thumbnails, and so on.

The answer is a custom scalar type in GraphQL.

Custom GraphQL Scalars

Scalar values in GraphQL are types like String, Int, Boolean (and a bunch of others).

GraphQL lets you create custom scalar types. That’s useful when the built-in types aren’t enough, or when you need a custom atomic data type.

Ideally, the photo query should look like:

photo(options:"200, false, true, face")

We need to pass in some options. That means updating the type definition (schema) for User:

type User {
  id: ID!
  name: String!
  photo(options: CloudinaryOptions): String
}

scalar CloudinaryOptions

Notice options takes a custom scalar type CloudinaryOptions (defined after the User type).

To create the custom scalar, import GraphQLScalarType in the resolver:

const { GraphQLScalarType } = require('graphql');

Then add this to the resolver:

CloudinaryOptions: new GraphQLScalarType({
  name: 'CloudinaryOptions',
  parseValue(value) {
    return value;
  },
  serialize(value) {
    return value;
  },
  parseLiteral(ast) {
    return ast.value.split(',');
  },
});

When creating a custom scalar type, you must specify these 3 functions: parseValue, serialize and parseLiteral:

  1. parseValue handles the value coming from the client.
  2. serialize sends the value back to the client.
  3. parseLiteral parses the client’s input from a query.

We access the options via parseLiteral(), which returns an array. If we specify photo(options:"200, false, true, face") in the query, parseLiteral() returns [200, false, true, face].

Now we can use that array in the resolver. Here’s the updated photo resolver:

photo: (parent, { options }) => {
  let url = cloudinary.url(parent.photo);
  if (options) {
    // width: Int, q_auto: Boolean, f_auto: Boolean, face: 'face'
    const [width, q_auto, f_auto, face] = options;
    const cloudinaryOptions = {
      ...(q_auto === 'true' && { quality: 'auto' }),
      ...(f_auto === 'true' && { fetch_format: 'auto' }),
      ...(face && { crop: 'thumb', gravity: 'face' }),
      width,
      secure: true,
    };
    url = cloudinary.url(parent.photo, cloudinaryOptions);
    return url;
  }
  return url;
};

We pull values from the array and build an options object for Cloudinary. The cloudinary.url() call returns a modified URL. The secure: true flag ensures the URL always starts with ‘https’, regardless of other options.

Refresh the application and check the user profile. We get a much smaller image (both in pixel width and file size), focused on the face.

Conclusion

We’ve seen how to manage images through a third-party service and custom scalar types in GraphQL. There are other ways to serve images via GraphQL, of course. But using a service that already handles hosting and transformations gives you a lot more than just displaying a picture.