EXPEDIA GROUP TECHNOLOGY — SOFTWARE
File Upload with GraphQL, Apollo Server, Hapi 18+, and React
How to stream binary data using GraphQL
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.
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
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
.
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.
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).
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