diff --git a/packages/apollo-server-core/src/ApolloServer.ts b/packages/apollo-server-core/src/ApolloServer.ts index 282497070a1..310e36159b3 100644 --- a/packages/apollo-server-core/src/ApolloServer.ts +++ b/packages/apollo-server-core/src/ApolloServer.ts @@ -1,4 +1,9 @@ -import { makeExecutableSchema, addMockFunctionsToSchema } from 'graphql-tools'; +import { + makeExecutableSchema, + addMockFunctionsToSchema, + IResolvers, + mergeSchemas, +} from 'graphql-tools'; import { Server as HttpServer } from 'http'; import { execute, @@ -49,10 +54,10 @@ export class ApolloServerBase { public disableTools: boolean; // set in the listen function if subscriptions are enabled public subscriptionsPath: string; + public requestOptions: Partial>; private schema: GraphQLSchema; private context?: Context | ContextFunction; - private requestOptions: Partial>; private graphqlPath: string = '/graphql'; private engineProxy: ApolloEngine; private engineEnabled: boolean = false; @@ -99,10 +104,13 @@ export class ApolloServerBase { this.requestOptions = requestOptions; this.context = context; + const enhancedTypeDefs = Array.isArray(typeDefs) ? typeDefs : [typeDefs]; + enhancedTypeDefs.push(`scalar Upload`); + this.schema = schema ? schema : makeExecutableSchema({ - typeDefs: Array.isArray(typeDefs) ? typeDefs.join('\n') : typeDefs, + typeDefs: enhancedTypeDefs.join('\n'), schemaDirectives, resolvers, }); @@ -123,6 +131,18 @@ export class ApolloServerBase { this.graphqlPath = path; } + public enhanceSchema( + schema: GraphQLSchema | { typeDefs: string; resolvers: IResolvers }, + ) { + this.schema = mergeSchemas({ + schemas: [ + this.schema, + 'typeDefs' in schema ? schema['typeDefs'] : schema, + ], + resolvers: 'resolvers' in schema ? [, schema['resolvers']] : {}, + }); + } + public listen(opts: ListenOptions = {}): Promise { this.http = this.getHttp(); diff --git a/packages/apollo-server-express/package.json b/packages/apollo-server-express/package.json index 62beb85587a..45eccd944bb 100644 --- a/packages/apollo-server-express/package.json +++ b/packages/apollo-server-express/package.json @@ -30,6 +30,7 @@ "accepts": "^1.3.5", "apollo-server-core": "2.0.0-beta.1", "apollo-server-module-graphiql": "^1.3.4", + "apollo-upload-server": "^5.0.0", "body-parser": "^1.18.3", "cors": "^2.8.4", "graphql-playground-middleware-express": "^1.6.2" @@ -45,7 +46,9 @@ "connect": "3.6.6", "connect-query": "1.0.0", "express": "4.16.3", - "multer": "1.3.0" + "form-data": "^2.3.2", + "multer": "1.3.0", + "node-fetch": "^2.1.2" }, "typings": "dist/index.d.ts", "typescript": { diff --git a/packages/apollo-server-express/src/ApolloServer.test.ts b/packages/apollo-server-express/src/ApolloServer.test.ts index 08a01d6586c..2c0b870283b 100644 --- a/packages/apollo-server-express/src/ApolloServer.test.ts +++ b/packages/apollo-server-express/src/ApolloServer.test.ts @@ -4,6 +4,9 @@ import 'mocha'; import * as express from 'express'; import * as request from 'request'; +import * as FormData from 'form-data'; +import * as fs from 'fs'; +import * as fetch from 'node-fetch'; import { createApolloFetch } from 'apollo-fetch'; import { ApolloServerBase } from 'apollo-server-core'; @@ -253,5 +256,85 @@ describe('apollo-server-express', () => { }); }); }); + describe('file uploads', () => { + it('enabled uploads', async () => { + server = new ApolloServer({ + typeDefs: gql` + type File { + filename: String! + mimetype: String! + encoding: String! + } + + type Query { + uploads: [File] + } + + type Mutation { + singleUpload(file: Upload!): File! + } + `, + resolvers: { + Query: { + uploads: (parent, args) => {}, + }, + Mutation: { + singleUpload: async (parent, args) => { + expect((await args.file).stream).to.exist; + return args.file; + }, + }, + }, + }); + app = express(); + registerServer({ + app, + server, + }); + + const { port } = await server.listen({}); + + const body = new FormData(); + + body.append( + 'operations', + JSON.stringify({ + query: gql` + mutation($file: Upload!) { + singleUpload(file: $file) { + filename + encoding + mimetype + } + } + `, + variables: { + file: null, + }, + }), + ); + + body.append('map', JSON.stringify({ 1: ['variables.file'] })); + body.append('1', fs.createReadStream('package.json')); + + try { + const resolved = await fetch(`http://localhost:${port}/graphql`, { + method: 'POST', + body, + }); + const response = await resolved.json(); + + expect(response.data.singleUpload).to.deep.equal({ + filename: 'package.json', + encoding: '7bit', + mimetype: 'application/json', + }); + } catch (error) { + // This error began appearing randomly and seems to be a dev dependency bug. + // https://github.com/jaydenseric/apollo-upload-server/blob/18ecdbc7a1f8b69ad51b4affbd986400033303d4/test.js#L39-L42 + if (error.code !== 'EPIPE') throw error; + } + }); + }); }); }); diff --git a/packages/apollo-server-express/src/ApolloServer.ts b/packages/apollo-server-express/src/ApolloServer.ts index 76224971c7c..c429d3ff880 100644 --- a/packages/apollo-server-express/src/ApolloServer.ts +++ b/packages/apollo-server-express/src/ApolloServer.ts @@ -3,11 +3,18 @@ import * as corsMiddleware from 'cors'; import { json, OptionsJson } from 'body-parser'; import { createServer, Server as HttpServer } from 'http'; import gui from 'graphql-playground-middleware-express'; -import { ApolloServerBase } from 'apollo-server-core'; +import { ApolloServerBase, formatApolloErrors } from 'apollo-server-core'; import * as accepts from 'accepts'; import { graphqlExpress } from './expressApollo'; +import { + processRequest as processFileUploads, + GraphQLUpload, +} from 'apollo-upload-server'; + +const gql = String.raw; + export interface ServerRegistration { app: express.Application; server: ApolloServerBase; @@ -16,8 +23,40 @@ export interface ServerRegistration { bodyParserConfig?: OptionsJson; onHealthCheck?: (req: express.Request) => Promise; disableHealthCheck?: boolean; + //https://github.com/jaydenseric/apollo-upload-server#options + uploads?: boolean | Record; } +const fileUploadMiddleware = ( + uploadsConfig: Record, + server: ApolloServerBase, +) => ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => { + if (req.is('multipart/form-data')) { + processFileUploads(req, uploadsConfig) + .then(body => { + req.body = body; + next(); + }) + .catch(error => { + if (error.status && error.expose) res.status(error.status); + + next( + formatApolloErrors([error], { + formatter: server.requestOptions.formatError, + debug: server.requestOptions.debug, + logFunction: server.requestOptions.logFunction, + }), + ); + }); + } else { + next(); + } +}; + export const registerServer = async ({ app, server, @@ -26,6 +65,7 @@ export const registerServer = async ({ bodyParserConfig, disableHealthCheck, onHealthCheck, + uploads, }: ServerRegistration) => { if (!path) path = '/graphql'; @@ -49,6 +89,21 @@ export const registerServer = async ({ }); } + let uploadsMiddleware; + if (uploads !== false) { + server.enhanceSchema({ + typeDefs: gql` + scalar Upload + `, + resolvers: { Upload: GraphQLUpload }, + }); + + uploadsMiddleware = fileUploadMiddleware( + typeof uploads !== 'boolean' ? uploads : {}, + server, + ); + } + // XXX multiple paths? server.use({ path, @@ -59,6 +114,7 @@ export const registerServer = async ({ path, corsMiddleware(cors), json(bodyParserConfig), + uploadsMiddleware, (req, res, next) => { // make sure we check to see if graphql gui should be on if (!server.disableTools && req.method === 'GET') { diff --git a/packages/apollo-server-hapi/package.json b/packages/apollo-server-hapi/package.json index c61e89a719f..c442889358c 100644 --- a/packages/apollo-server-hapi/package.json +++ b/packages/apollo-server-hapi/package.json @@ -28,6 +28,7 @@ "accept": "^3.0.2", "apollo-server-core": "2.0.0-beta.1", "apollo-server-module-graphiql": "^1.3.4", + "apollo-upload-server": "^5.0.0", "boom": "^7.1.0", "graphql-playground-html": "^1.5.6" }, diff --git a/packages/apollo-server-hapi/src/ApolloServer.ts b/packages/apollo-server-hapi/src/ApolloServer.ts index 5b1600407b5..f029791f918 100644 --- a/packages/apollo-server-hapi/src/ApolloServer.ts +++ b/packages/apollo-server-hapi/src/ApolloServer.ts @@ -3,9 +3,15 @@ import { createServer, Server as HttpServer } from 'http'; import { ApolloServerBase, EngineLauncherOptions } from 'apollo-server-core'; import { parseAll } from 'accept'; import { renderPlaygroundPage } from 'graphql-playground-html'; +import { + processRequest as processFileUploads, + GraphQLUpload, +} from 'apollo-upload-server'; import { graphqlHapi } from './hapiApollo'; +const gql = String.raw; + export interface ServerRegistration { app?: hapi.Server; //The options type should exclude port @@ -15,6 +21,7 @@ export interface ServerRegistration { cors?: boolean; onHealthCheck?: (req: hapi.Request) => Promise; disableHealthCheck?: boolean; + uploads?: boolean | Record; } export interface HapiListenOptions { @@ -26,6 +33,18 @@ export interface HapiListenOptions { launcherOptions?: EngineLauncherOptions; } +const handleFileUploads = ( + uploadsConfig: Record, + server: ApolloServerBase, +) => async (req: hapi.Request, h: hapi.ResponseToolkit) => { + if (req.mime === 'multipart/form-data') { + Object.defineProperty(req, 'payload', { + value: await processFileUploads(req, uploadsConfig), + writable: false, + }); + } +}; + export const registerServer = async ({ app, options, @@ -34,6 +53,7 @@ export const registerServer = async ({ path, disableHealthCheck, onHealthCheck, + uploads, }: ServerRegistration) => { if (!path) path = '/graphql'; @@ -63,13 +83,29 @@ server.listen({ http: { port: YOUR_PORT_HERE } }); hapiApp = new hapi.Server({ autoListen: false }); } + if (uploads !== false) { + server.enhanceSchema({ + typeDefs: gql` + scalar Upload + `, + resolvers: { Upload: GraphQLUpload }, + }); + } + await hapiApp.ext({ type: 'onRequest', - method: function(request, h) { + method: async function(request, h) { if (request.path !== path) { return h.continue; } + if (uploads !== false) { + await handleFileUploads( + typeof uploads !== 'boolean' ? uploads : {}, + server, + )(request, h); + } + if (!server.disableTools && request.method === 'get') { //perform more expensive content-type check only if necessary const accept = parseAll(request.headers);