EXPEDIA GROUP TECHNOLOGY — SOFTWARE

File Upload with GraphQL, Apollo Server, Hapi 18+, and React

How to stream binary data using GraphQL

Rohit Pandey
Expedia Group Technology

--

Product logos for Apollo, hapi, GraphQL, and React

GraphQL is a de facto standard for querying data in many companies due to its simplicity, performance, and ease of aggregating data from multiple sources. Our team at Vrbo™️ fetch or update data from a Hapi.js server using GraphQL. In our experience, with GraphQL we make a single request to fetch information, while with REST we had to make several heavy calls to fetch the same data. To update data we use mutation and voila — it’s done.

Logos of companies using GraphQL
Graphql adopters from apollographql

Benefits of GraphQL

1. Ask for what you need, get exactly that
2. Get many resources in a single request: While typical REST APIs require loading from multiple URLs, GraphQL APIs get all the data your app needs in a single request
3. Describe what’s possible with a type system: GraphQL APIs are organized in terms of types and fields, not endpoints
4. Move faster with powerful developer tools: GraphQL makes it easy to build powerful tools like GraphiQL
5. Evolve your API without versions: Add new fields and types to your GraphQL API without impacting existing queries
6. Bring your own data and code: Create a uniform API across your entire application

Decorative separator

I recently bumped into a problem where I had to stream binary data through GraphQL

Google revealed a few articles on how to upload a file through GraphQL. However, most of the solutions were for either Express or Koa. I couldn’t find a simple solution using GraphQL with hapi.

This post steps through a working demo of file upload, deployed with apollo-graphql for hapi on Repl and CodeSandbox.

1. Use create-react-app to create a React UI.

2. Write a server running the hapi framework for Node containing our upload typedefs and resolver function.

3. Fire a mutation when we trigger an onChange event from an input element in the React UI.

4. Read the file stream and write a file successfully on the server side.

Let’s get started!

Client-side React code with mutation

Use create-react-app to generate an app using the commands below:

npx create-react-app my-app
cd my-app
npm start or yarn start

Replace your package.json file with the below code:

{
"name": "my-app",
"version": "0.1.0",
"private": true,
"dependencies": {
"apollo-cache-inmemory": "^1.6.3",
"apollo-client": "^2.6.4",
"apollo-link": "^1.2.13",
"apollo-link-error": "^1.1.12",
"apollo-link-http": "^1.5.16",
"apollo-server": "^2.9.7",
"apollo-server-hapi": "^2.4.8",
"apollo-upload-client": "^11.0.0",
"react-apollo": "^3.1.3",
"graphql": "^14.5.8",
"hapi": "^17.5.2",
"inert": "^5.1.3",
"react": "^16.6.3",
"react-dom": "^16.6.3",
"react-scripts": "3.2.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [">0.2%","not dead","not op_mini all"],
"development": ["last 1 chrome version","last 1 firefox version","last 1 safari version"]
}
}

If you face below errors(optional).

Create .env file in the root of the directory and SKIP_PREFLIGHT_CHECK=true

.env

SKIP_PREFLIGHT_CHECK=true

Do npm/yarn install and start the app after updating the package.json file

npm install or yarn install
npm start or yarn install

Then open http://localhost:3000/ to see your app. Copy the below contents into your App.js:

import gql from "graphql-tag";
import { Mutation, ApolloProvider } from "react-apollo";
import { createUploadLink } from "apollo-upload-client";
export const UPLOAD_FILE = gql`
mutation uploadFile($file: Upload!) {
uploadFile(file: $file) {
filename
}
}`;
const client = new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors)
graphQLErrors.map(({ message, locations, path }) =>
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`));
if (networkError) {
console.log(`[Network error]: ${networkError}`);
}
}),
new createUploadLink({uri: 'http://localhost:4000/graphql'})]),
cache: new InMemoryCache()
});

In the code above, we create a constant, UPLOAD_FILE, which has a GraphQL mutation that accepts file as a data stream. Then we create an apollo-client instance with createUploadLink from apollo-upload-client.

With createUploadLink, you can create a terminating Apollo Link capable of file uploads. The options match createHttpLink. In the URI, provide the endpoint where we are running our Node.js server.

Now, add this <Mutation> inside your App.js. This will fire the mutation once a file is uploaded.

<ApolloProvider client={client}>
<Mutation mutation={UPLOAD_FILE}>
{uploadFile => (
<input type="file" required onChange={({
target: {validity, files: [file]}
}) => validity.valid && uploadFile({ variables: { file}})}/>
)}
</Mutation>
</ApolloProvider>

Final code in your App.js should look like this (If you face any error, just replace the app.js content with the below code):

import React from 'react';
import logo from './logo.svg';
import './App.css';
import gql from 'graphql-tag'
import { Mutation, ApolloProvider } from 'react-apollo';
import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { createUploadLink } from 'apollo-upload-client';
import { createHttpLink } from 'apollo-link-http';
import { ApolloLink } from 'apollo-link';
import { onError } from 'apollo-link-error';
export const UPLOAD_FILE = gql`
mutation uploadFile($file: Upload!) {
uploadFile(file: $file) {
filename
}
}`;
//create apollo clientconst client = new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) graphQLErrors.map(({ message, locations, path }) => console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`));
if (networkError) console.log(`[Network error]: ${networkError}`);}),new createUploadLink({ uri: 'http://localhost:4000/graphql'})]),
cache: new InMemoryCache()
});
function App() {
return (
<div className="App">
<ApolloProvider client={client}>
<Mutation mutation={UPLOAD_FILE}>
{uploadFile => (<input type="file" required onChange= {({target: { validity, files: [file] } }) =>validity.valid && uploadFile({ variables: { file } })}/>)}
</Mutation>
</ApolloProvider>
</div>);
}
export default App;

Server code

Create a new project, do npm init which will generate the package.json file for us. Replace the file content with the below lines:

{
"name": "graphql-server-example",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"apollo-server": "^2.9.7",
"apollo-server-hapi": "^2.9.7",
"graphql": "^14.5.8",
"hapi": "^18.1.0",
"inert": "^5.1.3"
}
}

Query

Let’s write the query. Create a file index.js.

Cartoon of man surrounded by question marks

Add the below code to it.

//index.js 
const { ApolloServer, gql } = require('apollo-server');
const typeDefs = gql`
type File {
filename: String!
mimetype: String!
encoding: String!
}
type Query {
uploads: [File]
}
type Mutation {
uploadFile(file: Upload!): File!
}
`;
// In uploadFile, the return type could be any message or anything, // which you want to send back.

Resolver code and Apollo server

Here is the resolver uploadFile for the mutation we are using to write the file to disk. We are using createReadStream() to read the file stream and createWriteStream() to write the stream to disk.

Add this to index.js:

const {createWriteStream} = require('fs');const resolvers = {
Mutation: {
async uploadFile(parent, { file }) {
const { createReadStream, filename, mimetype, encoding } = await file;
const stream = createReadStream();// Store the file in the filesystem.await new Promise((resolve, reject) => {
stream.on('error', error => {
unlink(path, () => {
reject(error);
});
}).pipe(createWriteStream(filename))
.on('error', reject)
.on('finish', resolve)
});
console.log('-----------file written');
return file;
}
}
};
// The ApolloServer constructor requires two parameters: your schema
// definition and your set of resolvers.
const server = new ApolloServer({ typeDefs, resolvers });// The `listen` method launches a web server.server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});

Your final code should look like this:

//index.js 
const { ApolloServer, gql } = require('apollo-server');
const {createWriteStream} = require('fs');const typeDefs = gql`
type File {
filename: String!
mimetype: String!
encoding: String!
}
type Query {
uploads: [File]
}
type Mutation {
uploadFile(file: Upload!): File!
}
`;
// In uploadFile, the return type could be any message or anything, // which you want to send back.const resolvers = {
Mutation: {
async uploadFile(parent, { file }) {
const { createReadStream, filename, mimetype, encoding } = await file;
const stream = createReadStream();// Store the file in the filesystem.await new Promise((resolve, reject) => {
stream.on('error', error => {
unlink(path, () => {
reject(error);
});
}).pipe(createWriteStream(filename))
.on('error', reject)
.on('finish', resolve)
});
console.log('-----------file written');
return file;
}
}
};
// The ApolloServer constructor requires two parameters: your schema
// definition and your set of resolvers.
const server = new ApolloServer({ typeDefs, resolvers });// The `listen` method launches a web server.server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});

Do npm install and npm start, and the server should be running 🚀 at http://localhost:4000/

Upload file from UI

You can see the file in your server side code 📂(in the root)

Upload multiple files

Uploading multiple files from the UI requires a few changes. We need to use array type data for files.

// App.js
export const UPLOAD_FILE = gql`
mutation uploadFile($files: [Upload]!) {
uploadFile(files: $files) {
filename
}
}`;
// App.js<input
id="files"
type="file"
multiple
required
onChange={({target: {validity, files}}) =>
validity.valid && uploadFile({variables: {files}})
}/>
// index.js mutation
// The return type could be any message or anything, which you want // to send back.
type Mutation {
uploadFile(files: [Upload]!): [File]!
}
//index.js resolverasync uploadFile(parent, {files}) {
files.map(async (file) => {
const {createReadStream, filename} = await file;
const stream = createReadStream();

await new Promise((resolve, reject) => {
stream.on('error', (error) => {
console.log('writerror....', error);
})
.pipe(createWriteStream(filename))
.on('error', reject)
.on ('finish', resolve);
});
console.log('-----------file written');
});
return files;}

Here is the online complete working solution for resolver and query running on hapi 18.1.0.

Server-side code: Go to the repl.it link. Click on the Run button. It will install all the dependencies and run the server.

Client-side code: Open the sandbox link and the UI should be visible with the file input where you can upload the file.
1. Open chrome devtools.

2. Upload a file and you will see the graphql request under the networks tab, which will be pointing to the repl server we created earlier.

Screenshot of browser showing GraphQL call
graphql mutation triggered

You should be able to see the uploaded file in the left hand side of the repl. If you are unable to see it, just refresh the page after few minutes(repl file update is a bit wonky).

Screenshot of repl.it showing that file has been uploaded
File uploaded to server

We are done!

Image of runner crossing a finish line
We are done!

Further readings and references

Graphql: https://graphql.org

Apollo server has support for file upload: https://www.apollographql.com/docs/apollo-server/data/file-uploads/

File Uploads with Apollo Server 2.0: https://blog.apollographql.com/file-uploads-with-apollo-server-2-0-5db2f3f60675

Logos of the different companies within Expedia Group

--

--