diff --git a/changelog.md b/changelog.md index 5ca4706..ad0d124 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,11 @@ ## Next +* New API to support the [GraphQL multipart request spec v2.0.0-alpha.2](https://github.com/jaydenseric/graphql-multipart-request-spec/releases/tag/v2.0.0-alpha.2). Files no longer upload to the filesystem; [readable streams](https://nodejs.org/api/stream.html#stream_readable_streams) are used in resolvers instead. +* Export a new `Upload` scalar type to use in place of the old `Upload` input type. It represents a file upload promise that resolves an object containing `stream`, `filename`, `mimetype` and `encoding`. +* Deprecated the `uploadDir` middleware option. +* Added new `maxFieldSize`, `maxFileSize` and `maxFiles` middleware options. +* `graphql` is now a peer dependency. * Middleware are now arrow functions. ## 3.0.0 diff --git a/package.json b/package.json index f982bd1..e3d28d2 100644 --- a/package.json +++ b/package.json @@ -33,16 +33,19 @@ "node": ">=7.6" }, "dependencies": { - "formidable": "^1.1.1", - "mkdirp": "^0.5.1", + "busboy": "^0.2.14", "object-path": "^0.11.4" }, + "peerDependencies": { + "graphql": "^0.11.0" + }, "devDependencies": { "@babel/cli": "^7.0.0-beta.32", "@babel/core": "^7.0.0-beta.32", "@babel/preset-env": "^7.0.0-beta.32", "eslint": "^4.11.0", "eslint-plugin-prettier": "^2.3.1", + "graphql": "^0.11.7", "husky": "^0.14.3", "lint-staged": "^5.0.0", "prettier": "^1.8.2" diff --git a/readme.md b/readme.md index ae1eae5..9955257 100644 --- a/readme.md +++ b/readme.md @@ -4,19 +4,25 @@ [![npm version](https://img.shields.io/npm/v/apollo-upload-server.svg)](https://npm.im/apollo-upload-server) ![Licence](https://img.shields.io/npm/l/apollo-upload-server.svg) [![Github issues](https://img.shields.io/github/issues/jaydenseric/apollo-upload-server.svg)](https://github.com/jaydenseric/apollo-upload-server/issues) [![Github stars](https://img.shields.io/github/stars/jaydenseric/apollo-upload-server.svg)](https://github.com/jaydenseric/apollo-upload-server/stargazers) -Enhances [Apollo](https://apollographql.com) for intuitive file uploads via GraphQL mutations or queries. Use with [apollo-upload-client](https://github.com/jaydenseric/apollo-upload-client). +Enhances [Apollo](https://apollographql.com) for intuitive file uploads via GraphQL queries or mutations. Use with [apollo-upload-client](https://github.com/jaydenseric/apollo-upload-client). ## Setup -Install with [npm](https://www.npmjs.com): +Install with peer dependencies using [npm](https://www.npmjs.com): ``` -npm install apollo-upload-server +npm install apollo-upload-server graphql ``` ### Middleware -Add the server middleware just before [graphql-server](https://github.com/apollographql/graphql-server). +Add the middleware just before [graphql-server](https://github.com/apollographql/graphql-server). + +#### Options + +* `maxFieldSize` (integer): Max allowed non-file multipart form field size in bytes; enough for your queries (default: 1 MB). +* `maxFileSize` (integer): Max allowed file size in bytes (default: Infinity). +* `maxFiles` (integer): Max allowed number of files (default: Infinity). #### [Koa](http://koajs.com) @@ -28,10 +34,7 @@ import { apolloUploadKoa } from 'apollo-upload-server' router.post( '/graphql', koaBody(), - apolloUploadKoa({ - // Defaults to OS temp directory - uploadDir: './uploads' - }), + apolloUploadKoa(/* Options */), graphqlKoa(/* … */) ) ``` @@ -46,33 +49,40 @@ import { apolloUploadExpress } from 'apollo-upload-server' app.use( '/graphql', bodyParser.json(), - apolloUploadExpress({ - // Defaults to OS temp directory - uploadDir: './uploads' - }), + apolloUploadExpress(/* Options */), graphqlExpress(/* … */) ) ``` #### Custom middleware -If the middleware you need is not available, import the async `processRequest` function to make your own: +To make your own middleware import the `processRequest` async function: ```js import { processRequest } from 'apollo-upload-server' ``` -### GraphQL schema +### `Upload` scalar + +A file upload promise that resolves an object containing: -Add an input type for uploads to your schema. You can name it anything but it must have this shape: +* `stream` +* `filename` +* `mimetype` +* `encoding` -```graphql -input Upload { - name: String! - type: String! - size: Int! - path: String! -} +It must be added to your types and resolvers: + +```js +import { makeExecutableSchema } from 'graphql-tools' +import { GraphQLUpload } from 'apollo-upload-server' + +const schema = makeExecutableSchema({ + typeDefs: [`scalar Upload`], + resolvers: { + Upload: GraphQLUpload + } +}) ``` ### Client @@ -83,9 +93,9 @@ Also setup [apollo-upload-client](https://github.com/jaydenseric/apollo-upload-c Once setup, on the client use [`FileList`](https://developer.mozilla.org/en/docs/Web/API/FileList), [`File`](https://developer.mozilla.org/en/docs/Web/API/File) and [`ReactNativeFile`](https://github.com/jaydenseric/apollo-upload-client#react-native) instances anywhere within query or mutation input variables. See the [client usage](https://github.com/jaydenseric/apollo-upload-client#usage). -The files upload to a configurable temp directory on the GraphQL server. `Upload` input type metadata replaces file instances in the arguments received by the resolver. +Files upload via a [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec) and appear as [`Upload` scalars](#upload-scalar) in resolver arguments. -See the [example API and client](https://github.com/jaydenseric/apollo-upload-examples) +See the [example API and client](https://github.com/jaydenseric/apollo-upload-examples). ## Support diff --git a/src/index.mjs b/src/index.mjs index e572ade..7855418 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1,52 +1,79 @@ -import mkdirp from 'mkdirp' -import formidable from 'formidable' +import { GraphQLScalarType } from 'graphql' +import Busboy from 'busboy' import objectPath from 'object-path' -export function processRequest(request, { uploadDir } = {}) { - // Ensure provided upload directory exists - if (uploadDir) mkdirp.sync(uploadDir) +export const GraphQLUpload = new GraphQLScalarType({ + name: 'Upload', + description: + 'The `Upload` scalar type represents a file upload promise that resolves ' + + 'an object containing `stream`, `filename`, `mimetype` and `encoding`.', + parseValue: value => value, + parseLiteral() { + throw new Error('Upload scalar literal unsupported') + }, + serialize() { + throw new Error('Upload scalar serialization unsupported') + } +}) - const form = formidable.IncomingForm({ - // Defaults to the OS temp directory - uploadDir - }) - - // Parse the multipart form request - return new Promise((resolve, reject) => { - form.parse(request, (error, { operations }, files) => { - if (error) reject(new Error(error)) - - // Decode the GraphQL operation(s). This is an array if batching is - // enabled. - operations = JSON.parse(operations) - - // Check if files were uploaded - if (Object.keys(files).length) { - // File field names contain the original path to the File object in the - // GraphQL operation input variables. Relevent data for each uploaded - // file now gets placed back in the variables. - const operationsPath = objectPath(operations) - Object.keys(files).forEach(variablesPath => { - const { name, type, size, path } = files[variablesPath] - operationsPath.set(variablesPath, { name, type, size, path }) - }) +export const processRequest = ( + request, + { maxFieldSize, maxFileSize, maxFiles } = {} +) => + new Promise(resolve => { + const busboy = new Busboy({ + headers: request.headers, + limits: { + fieldSize: maxFieldSize, + fields: 2, // Only operations and map + fileSize: maxFileSize, + files: maxFiles } + }) + + // GraphQL multipart request spec: + // https://github.com/jaydenseric/graphql-multipart-request-spec + + let operations + let operationsPath - // Provide fields for replacement request body - resolve(operations) + busboy.on('field', (fieldName, value) => { + switch (fieldName) { + case 'operations': + operations = JSON.parse(value) + operationsPath = objectPath(operations) + break + case 'map': { + for (const [mapFieldName, paths] of Object.entries( + JSON.parse(value) + )) { + // Upload scalar + const upload = new Promise(resolve => + busboy.on( + 'file', + (fieldName, stream, filename, encoding, mimetype) => + fieldName === mapFieldName && + resolve({ stream, filename, mimetype, encoding }) + ) + ) + + for (const path of paths) operationsPath.set(path, upload) + } + resolve(operations) + } + } }) + + request.pipe(busboy) }) -} export const apolloUploadKoa = options => async (ctx, next) => { - // Skip if there are no uploads if (ctx.request.is('multipart/form-data')) ctx.request.body = await processRequest(ctx.req, options) await next() } export const apolloUploadExpress = options => (request, response, next) => { - // Skip if there are no uploads if (!request.is('multipart/form-data')) return next() processRequest(request, options) .then(body => {