End-to-End GraphQL File Uploads with Apollo Server and Apollo Client: A Comprehensive Guide

End-to-End GraphQL File Uploads with Apollo Server and Apollo Client: A Comprehensive Guide

We recently started partnering (where I currently work) with some of our customers to co-create content, and as a result we needed a way to stream files from the client to the server, through GraphQL. This added an extra layer of complexity, which took us some time to effectively hash-out, and hence the inspiration for this blog.

In this blog, I'll be walking you through (with code snippets included) how to enhance your GraphQL set up, to allow for file uploads. I intend this tutorial to be an in-depth one, that goes full circle starting with the server set up, and implementation (using Apollo Server), and going all the way to the client set up and implementation (using Apollo Client). The tech-stack specifications for the client will be Apollo client (and I'll try and generalize the config as much as possible since there's a gazillion javascript frontend frameworks and I can't possibly touch on all of them. There'll be probably another new javascript frontend framework by the time you are reading this lol). For the server, the set-up is mostly inclined towards NestJS, but can be co-adopted to other frameworks as well.


1. Spinning Up the Backend: Dependencies & Configuration

So for starters, create a nestjs project (if you don't have one already). You can skip this step if you have your own set-up that isn't nestjs-related.

nest new project-name

Secondly, install necessary dependencies (I'm using TypeScript, which should - at the risk of breaking the one forbidden software rule of having an opinion - be your default at this point. If you are writing server-side code without static types, then you must be the one-true master, and I'd love to learn your ways). Skip the nestjs apollo dependencies if you have a different backend set up:

npm i @nestjs/graphql @nestjs/apollo @apollo/server@^4.11.2 graphql graphql-upload-ts

Next, we need to configure our graphql set-up.

For the nestjs category:

Open app.module.ts and make sure your graphql config matches mine (especially since v10 of Apollo Server removed it's built-in upload handler):

// ...other imports
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';
// ...other imports
GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  playground: false, // Let's get fancy with an alternative playground.
  plugins: [
    ApolloServerPluginLandingPageLocalDefault({
      embed: true,
    }),
  ],
  uploads: false, // Disable Apollo’s default upload handling if on NestJS 9+ (and Apollo Server version 10 and above).
  autoSchemaFile: 'src/schema.gql', // Adjust if your path differs!
  sortSchema: true,
  introspection: true,
  context: ({ req, res }) => ({ req, res }),
})
// ...other imports

Then configure the graphqlUploadExpress middleware within the same app.module.ts file:

export class GraphQLAppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(
      // apply the upload middleware, setting reasonable defaults.
        graphqlUploadExpress({
          maxFieldSize:100 * 1024 * 1024, // 100MB
          maxFileSize: 100 * 1024 * 1024, // 100MB
          maxFiles: 10, // 10 files per request
        })
      )
      .forRoutes('graphql');
  }
}

for the bare-metal express server category:

const express = require('express');
const expressGraphql = require('express-graphql');
const { graphqlUploadExpress } = require('graphql-upload-ts');

express()
  .use(
    '/graphql',
    // apply the upload middleware, setting reasonable defaults.
    graphqlUploadExpress({
      maxFieldSize:100 * 1024 * 1024, // 100MB
      maxFileSize: 100 * 1024 * 1024, // 100MB
      maxFiles: 10, // 10 files per request
    }),
    // Replace with your schema path
    expressGraphql({ schema: require('./schema.gql') })
  )
  .listen(3000);

2. Server Models, DTOs, & Resolvers: Building Your Upload Endpoint

Next, we'll define our model, which we'll call BigFileDataModel. For nestjs users, I'm using the code-first approach. For the rest of the readers, I'll also include a snippet of the resulting schema to include in your schema files.

The typescript definition:

import { Field, InputType } from '@nestjs/graphql';
import { FileUpload, GraphQLUpload } from 'graphql-upload-ts';
import { IsIn, IsNotEmpty } from 'class-validator';

@InputType()
export class BigFileDataModel {
  @Field()
  @IsNotEmpty()
  @IsIn(['mp4', 'mov', 'avi', 'mkv', 'webp', 'jpeg', 'jpg', 'png'])
  fileType: string;

  @Field(() => GraphQLUpload)
  contents: Promise<FileUpload>;
}

The equivalent schema:

"""The `Upload` scalar type represents a file upload."""
scalar Upload

input BigFileDataModel {
  contents: Upload!
  fileType: String!
}

Our BigFileDataModel has a file type and contents. The fileType is the file extension (so .png, .jpg and so on), and the contents is the binary file itself.

A quick thing to note: Make sure the data types match the typescript example if you are using schema-first approach, specifically the contents (which will be the file being streamed), which returns a promise when all the file bytes have finished transmitting.

Request & Response DTOs

Next, we'll create our DTOs that will be used by a resolver (which we'll create in a future step). The DTOs will be basically a request and response DTO (Data Transfer Object). For nestjs readers using the code-first approach, here's the TypeScript definitions.

Our Request DTO: Request DTO:

import { ArgsType, Field } from '@nestjs/graphql';

@ArgsType()
export class UploadFileRequest {
  @Field(() => BigFileDataModel)
  file: BigFileDataModel;
}

Response DTO:

import { Field, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class _UploadFileResponse {
  @Field({ nullable: true })
  file: string;
}

// Use your preferred base response structure, here I'm assuming inheritance/mixins:
@ObjectType()
export class UploadFileResponse extends StandardGraphQLResponse(_UploadFileResponse) {}

For our schema-first readers, here's the equivalent schema:

"""The `Upload` scalar type represents a file upload."""
scalar Upload

input BigFileDataModel {
  contents: Upload!
  fileType: String!
}

"""The section above has been included from a previous step for consistency """

type UploadFileResponse {
  file: String!
}

type Mutation {
  uploadFile(file: BigFileDataModel!): UploadFileResponse!
}

The request DTO basically takes an argument file which specifies the file properties we are sending from the client.

Our Resolver and FileUploadService class

At this point I don't think I need a 'nestjs code-first approach users' and 'schema-first users'. I assume you'll be smart enough to deduce which code snippet is for you moving forward.

Our typescript resolver (feel free to adapt this without the annotations to your resolver definition):

import { Resolver, Mutation, Args } from '@nestjs/graphql';

@Resolver('FileUploadResolver')
export class FileUploadResolver {
  constructor(private readonly fileUploadService: FileUploadService) {}

  @Mutation(() => UploadFileResponse)
  async uploadFile(@Args() req: UploadFileRequest) {
    const res = await this.fileUploadService.uploadToFirebase({
      file: req.file.contents,
      fileType: req.file.fileType,
    });
    return { file: res };
  }
}

The resolver basically calls our file upload service (which uploads the binary file to Google Storage, and returns a string representing the public url).

Here's the definition of our FileUploadService.ts, which you can feel free to re-use in your personal services requiring file upload.

import { Injectable } from '@nestjs/common';
import { Readable } from 'stream';
import { FileUpload } from 'graphql-upload-ts';

@Injectable()
export class FileUploadService {
  constructor(private readonly folderName: string) {}

  async uploadToFirebase({
    file,
    fileType,
  }: {
    file: Promise<FileUpload>;
    fileType?: string;
  }): Promise<string> {
    const buffer = await this.streamToBuffer((await file).createReadStream());
    const _fileName = `${Date.now()}-${Math.round(Math.random() * 1e9)}.${fileType}`;
    
    // Check the definition for this method below
    return await this.saveFileToStorage(this.folderName, _fileName, buffer);
  }

  private async streamToBuffer(readableStream: Readable): Promise<Buffer> {
    return new Promise((resolve, reject) => {
      const chunks: Buffer[] = [];
      readableStream.on('data', (data) => {
        if (typeof data === 'string') {
          chunks.push(Buffer.from(data, 'utf-8'));
        } else {
          chunks.push(data);
        }
      });
      readableStream.on('end', () => resolve(Buffer.concat(chunks)));
      readableStream.on('error', reject);
    });
  }

  // Stub: wire this up to your favorite storage backend
  private async saveFileToStorage(folder: string, fileName: string, buffer: Buffer): Promise<string> {
    // Implementation left as an exercise for the reader. Feel free to user Google storage/S3/local-fs or your own implementation for this.
    return `https://storage.example.com/${folder}/${fileName}`;
  }
}

There you have it - we have our server set up and ready to receive graphql file uploads. You can stop here if you're a just-backend engineer and hate anything to do with HTML/CSS/JavaScript.

For our full-stack engineers (yes, you rank higher than the just-backend engineers) who also want to set up a client, read on.


3. Client-Side Wizardry: Uploading Files with Apollo Client

Let's face it: JavaScript frontend frameworks multiply like rabbits. As a result, I'll try my best to keep the client implementation as framework-agnostic as possible. I'll use Apollo Client as an agnostic toolkit (since it's a separate implementation that does not depend on any frameworks, and is in the blog title).

Install necessary dependencies:

npm install @apollo/client apollo-upload-client

Setup Apollo Client Setup (with Upload)

Set up your Apollo Client (which you can use in your ApolloProvider in the case of Next.js. For other frameworks, you're on your own here):

import { ApolloClient, InMemoryCache } from '@apollo/client';
import { createUploadLink } from 'apollo-upload-client';
import { setContext } from '@apollo/client/link/context';

const httpLink = createUploadLink({
  uri: 'http://localhost:4000/graphql', // Plug in your server URL!
});

const headersLink = setContext((_, { headers }) => ({
  headers: {
    ...headers,
    'Apollo-Require-Preflight': 'true', // Enable CORS for file uploads
  },
}));

const apolloClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: headersLink.concat(httpLink),
  ssrMode: typeof window === 'undefined', // Server-side support, if needed
});

export default apolloClient;

In the example above, we create two links: one for injecting headers, and the other, a http terminating link for file upload, provided by the apollo-upload-client package. Then we include the 'Apollo-Require-Preflight': 'true' header, which is needed for enabling cors. This is outside the scope of this tutorial, but if you'd like to find out more, you can here;

The GraphQL Mutation

mutation uploadFile($file: BigFileDataModel!) {
  uploadFile(file: $file) {
    file
  }
}

File Input and Upload Logic

Here’s a barebones React-flavored file uploader. You can adapt this for your front-end framework of choice.

import { useMutation } from '@apollo/client';
const MUTATION = /* see mutation above */;

function UploadFile() {
  const [mutate] = useMutation(MUTATION);

  return (
    <input
      type="file"
      required
      onChange={({
        target: {
          validity,
          files: [file],
        },
      }) => {
        if (validity.valid)
          mutate({
            variables: {
              file,
            },
          });
      }}
    />
  );
}

I hope the code snippets above are self-explanatory (considering this is applicable to all frontend frameworks). If you run into any issues, always feel free to tag me in the comment section, and I'll respond as soon as I can!

Wrapping Up...

You should now have a working, end-to-end GraphQL file upload system, complete with server, DTOs, middleware, resolvers, upload streaming, Apollo Client, and a front-end implementation. If there's a framework or storage backend I missed, or you have a better implementation, drop your thoughts in the comments! Until next time, happy hacking!

Similar Posts