File uploads
Implementing file uploads with GraphQL
To view this content, buy the book! 馃槂馃檹
Or if you鈥檝e already purchased.
File uploads
Originally, web servers saved files to their hard drives or to colocated file servers. Most modern web servers use a third-party file-storage service like Amazon S3 or Cloudinary. When a user wants to upload a file, there are a few different ways the client can get it to a storage service:
- Client-side: The client sends the file directly to the storage service.
- Signed: Our API server gives a signature to the client to give to the storage service along with the file. If our API server doesn鈥檛 give the client a signature (for any reason鈥攆or example the client isn鈥檛 logged in, or the logged-in user doesn鈥檛 have upload permissions), then the storage service won鈥檛 accept the file.
- Unsigned: Our server is not involved, and the storage service accepts any file from any client.
- Server-side: The client sends the file to our server, and we forward it to the storage service.
- Through GraphQL: The file goes through our GraphQL endpoint.
- Outside GraphQL: We create a separate endpoint or server for the file to go through.
We recommend unsigned client-side file uploads unless the lack of signatures becomes a problem. If it does, we suggest switching to signed client-side. We prefer unsigned file uploads because they鈥檙e the easiest to set up. And the client-side upload process is faster than server-side and reduces load on the GraphQL server.
Not all storage services support client-side uploads, and among those that do, only some support unsigned uploads. S3, for instance, doesn鈥檛 really support it (we can configure an S3 bucket for public write access, but then anyone can delete user uploads). Cloudinary not only supports unsigned uploads, but they also take security measures to prevent abuse.
In the first section we鈥檒l go over client-side uploads, and in the second we鈥檒l do server-side through GraphQL.
Client-side
If you鈥檙e jumping in here,
git checkout 25_0.2.0
(tag 25_0.2.0, or compare 25...files)
In this section we鈥檒l add the server code to support an unsigned client-side upload鈥攁nd at the end, we鈥檒l show the additional code needed for a signed upload. All we need is a mutation for the client to tell the server the filename, ID, or path, depending on which file-storage service we鈥檙e using. If we wanted to make it general-purpose, we could use the file鈥檚 full URL instead. For the Guide, we鈥檒l use Cloudinary, which gives the client the file鈥檚 path after the upload is complete (the client-side upload process is described in Chapter 6). The server then combines the path鈥攆or example v1551850855/jeresig.jpg
鈥攚ith our account URL (https://res.cloudinary.com/graphql/
) to form the full URL:
https://res.cloudinary.com/graphql/v1551850855/jeresig.jpg
We鈥檒l use the file-upload feature to allow users to add a profile photo (instead of using their current GitHub photo), so we鈥檒l call the mutation setMyPhoto
and add it to User.graphql
:
extend type Mutation {
...
setMyPhoto(path: String!): User!
}
Since setMyPhoto
will be changing a User
field, we return the modified User
object.
In the resolver, we check if the client is logged in and call a new data source method setPhoto()
:
export default {
...
Mutation: {
createUser: ...,
setMyPhoto(_, { path }, { user, dataSources }) {
if (!user) {
throw new ForbiddenError('must be logged in')
}
return dataSources.users.setPhoto(path)
}
}
}
The method constructs the full photo URL, saves it to the database, and returns the updated user object:
export default class Users extends MongoDataSource {
...
async setPhoto(path) {
const { user } = this.context
const photo = `https://res.cloudinary.com/graphql/${path}`
await this.collection.updateOne({ _id: user._id }, { $set: { photo } })
return {
...user,
photo
}
}
}
Now that some user documents will contain a photo
field, we need to update our resolver:
export default {
...
User: {
id: ...,
email: ...,
photo(user) {
if (user.photo) {
return user.photo
}
// user.authId: 'github|1615'
const githubId = user.authId.split('|')[1]
return `https://avatars.githubusercontent.com/u/${githubId}`
},
createdAt: ...
},
Mutation: {
createUser: ...,
setMyPhoto: ...
}
}
We return early if the user
object fetched from the database has a photo
property.
We can test out the mutation in Playground with either a valid Authorization header or by hard coding the authId
in src/context.js
:
If we wanted to do signed client-side upload, we鈥檇 need to make a Query for the client to fetch the signature. Our resolver would call cloudinary.utils.api_sign_request() like this:
export default {
Query: {
...
uploadSignature(_, { uploadParams }, { user }) {
if (!user) {
throw new ForbiddenError('must be logged in')
}
return cloudinary.utils.api_sign_request(uploadParams, CLOUDINARY_API_SECRET)
}
}
}
Then the client would send the signature along with the file to Cloudinary鈥檚 servers (and we would disable unsigned uploads in our Cloudinary account settings).
If we were using Amazon S3, then we鈥檇 use the s3.createPresignedPost()
function to create the signature.
Server-side
If you鈥檙e jumping in here,
git checkout files_0.2.0
(tag files_0.2.0, or compare files...files2)
We go over the differences between client-side and server-side above. In this section, we鈥檒l do server-side file uploads, where the client sends the file to the GraphQL server, which sends it to the storage service (we could send to Cloudinary again, but we鈥檒l use Amazon S3 this time for diversity). There are different methods for the client to send the file, and the most common is a multipart HTTP request, which works through:
- an
Upload
scalar provided by Apollo Server - the Apollo Link
apollo-upload-client
on the client side
We create a mutation with an argument of type Upload
:
extend type Mutation {
createUser(user: CreateUserInput!, secretKey: String!): User
setMyPhoto(path: String!): User!
uploadMyPhoto(file: Upload!): User!
}
We鈥檒l need an instance of the AWS S3 client library (aws-sdk
) to upload to S3:
import AWS from 'aws-sdk'
export default new AWS.S3()
We鈥檒l import and use it in the resolver:
import s3 from '../util/s3'
const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
export default {
...
Mutation: {
...
uploadMyPhoto: async (_, { file }, { user, dataSources }) => {
if (!user) {
throw new ForbiddenError('must be logged in')
}
const { createReadStream, filename, mimetype } = await file
if (!IMAGE_MIME_TYPES.includes(mimetype)) {
throw new InputError({ file: 'must be an image file' })
}
const stream = createReadStream()
const { Location: fileUrl } = await s3
.upload({
Bucket: 'guide-user-photos',
Key: filename,
Body: stream
})
.promise()
return dataSources.users.setPhoto(fileUrl)
}
}
}
We first check if the user is logged in, then we check the file type (valid values taken from a list of MIME types), and then we create a Node.js file stream, which we pass to s3.upload()
along with the filename and S3 bucket (the top-level folder in S3, and the subdomain of the file鈥檚 URL). Finally, we call the data source setPhoto()
method, which used to take a path, but let鈥檚 refactor it to take a full URL:
export default class Users extends MongoDataSource {
...
async setPhoto(photo) {
const { user } = this.context
await this.collection.updateOne({ _id: user._id }, { $set: { photo } })
return {
...user,
photo
}
}
}
Changing the parameter means we need to update where we used it previously:
export default {
...
Mutation: {
createUser...
setMyPhoto(_, { path }, { user, dataSources }) {
if (!user) {
throw new ForbiddenError('must be logged in')
}
return dataSources.users.setPhoto(
`https://res.cloudinary.com/graphql/${path}`
)
},
uploadMyPhoto...
}
}
We pass the full cloudinary URL instead of just the path.
In order for the AWS SDK to authenticate our account, we need to add AWS_ACCESS_KEY_ID
and AWS_SECRET_ACCESS_KEY
to our .env
.
To test this section yourself, you need an AWS account, a bucket created in the S3 management console, and access keys created in the Identity and Access Management console. You鈥檇 replace
'guide-user-photos'
insrc/resolvers/User.js
with your bucket name, and you鈥檇 put your own access keys in.env
. Then you鈥檇 write a test like this or create a small web app that usedapollo-upload-client
to send a file in anuploadMyPhoto
Mutation.
When the uploadMyPhoto
Mutation is run, the upload is successful, and the server saves a URL like this in the photo
field of the current user鈥檚 MongoDB document:
https://guide-user-photos.s3.amazonaws.com/filename.jpg