From 02a1e9d6d4b7bc4cb3f87fceb63cbbacd3e2c4e0 Mon Sep 17 00:00:00 2001 From: David Heidrich Date: Fri, 5 Apr 2024 14:59:28 +0200 Subject: [PATCH] feat: added support for `nullable` uploads in multipart request --- .../multipartFormData.spec.ts.snap | 412 +++++++++++++ .../requests/multipartFormData.spec.ts | 71 ++- src/graphql-upload/Upload.ts | 8 +- .../process-request.spec.ts.snap | 2 + .../__snapshots__/utils.spec.ts.snap | 254 ++++++++ .../__tests__/process-request.spec.ts | 44 +- src/graphql-upload/__tests__/utils.spec.ts | 14 + src/graphql-upload/expressMiddleware.ts | 4 +- src/graphql-upload/processRequest.ts | 550 ++++++++++-------- src/graphql-upload/utils.ts | 14 + 10 files changed, 1104 insertions(+), 269 deletions(-) create mode 100644 src/graphql-upload/__tests__/__snapshots__/utils.spec.ts.snap create mode 100644 src/graphql-upload/__tests__/utils.spec.ts create mode 100644 src/graphql-upload/utils.ts diff --git a/src/__tests__/requests/__snapshots__/multipartFormData.spec.ts.snap b/src/__tests__/requests/__snapshots__/multipartFormData.spec.ts.snap index 149da02..ccb3273 100644 --- a/src/__tests__/requests/__snapshots__/multipartFormData.spec.ts.snap +++ b/src/__tests__/requests/__snapshots__/multipartFormData.spec.ts.snap @@ -19,6 +19,18 @@ exports[`FormData should allow mixed optional files 1`] = ` } `; +exports[`FormData should allow optional files 1`] = ` +{ + "uploadOptionalFiles": [], +} +`; + +exports[`FormData should allow optional files and file in combination 1`] = ` +{ + "uploadOptionalFilesAndFile": [], +} +`; + exports[`FormData should allow single optional files 1`] = ` { "uploadAMaybeFile": Any, @@ -1168,6 +1180,386 @@ exports[`FormData should create the correct schema 1`] = ` primaryImage: $primaryImage secondaryImage: $secondaryImage ) +}", + }, + }, + "/upload-optional-files-and-file/{id}": { + "post": { + "operationId": "uploadOptionalFilesAndFile", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + "x-graphql-variable-name": "id", + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "nullable": true, + "type": "object", + }, + }, + "multipart/form-data": { + "schema": { + "properties": { + "coverPicture": { + "allOf": [ + { + "$ref": "#/components/schemas/Upload", + }, + ], + "nullable": true, + "type": "string", + "x-graphql-variable-name": "coverPicture", + }, + "files": { + "allOf": [ + { + "$ref": "#/components/schemas/Uploads", + }, + ], + "nullable": true, + "type": "array", + "x-graphql-variable-name": "files", + }, + }, + "type": "object", + }, + }, + }, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "uploadOptionalFilesAndFile": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "uploadOptionalFilesAndFile", + ], + "type": "object", + }, + }, + }, + "description": "Success", + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "errors": { + "items": { + "properties": { + "errorCode": { + "type": "string", + }, + "location": { + "type": "string", + }, + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "type": "object", + }, + "type": "array", + }, + }, + "type": "object", + }, + }, + }, + "description": "Invalid request", + }, + "415": { + "content": { + "application/json": { + "schema": { + "properties": { + "errors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + }, + "type": "object", + }, + "type": "array", + }, + }, + "type": "object", + }, + }, + }, + "description": "Unsupported Media Type", + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "description": "Branch of data that does not contain errors", + "nullable": true, + "properties": { + "uploadOptionalFilesAndFile": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "uploadOptionalFilesAndFile", + ], + "type": "object", + }, + "errors": { + "items": { + "properties": { + "locations": { + "items": { + "properties": { + "column": { + "type": "integer", + }, + "line": { + "type": "integer", + }, + }, + "type": "object", + }, + "type": "array", + }, + "message": { + "type": "string", + }, + "path": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "type": "object", + }, + "type": "array", + }, + }, + "type": "object", + }, + }, + }, + "description": "Internal Server Error", + }, + }, + "x-graphql-operation": "mutation uploadOptionalFilesAndFile($id: String!, $files: Uploads, $coverPicture: Upload) { + uploadOptionalFilesAndFile( + id: $id + otherFiles: $files + coverPicture: $coverPicture + ) +}", + }, + }, + "/upload-optional-files/{id}": { + "post": { + "operationId": "uploadOptionalFiles", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + "x-graphql-variable-name": "id", + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "nullable": true, + "type": "object", + }, + }, + "multipart/form-data": { + "schema": { + "properties": { + "files": { + "allOf": [ + { + "$ref": "#/components/schemas/Uploads", + }, + ], + "nullable": true, + "type": "array", + "x-graphql-variable-name": "files", + }, + }, + "type": "object", + }, + }, + }, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "uploadOptionalFiles": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "uploadOptionalFiles", + ], + "type": "object", + }, + }, + }, + "description": "Success", + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "errors": { + "items": { + "properties": { + "errorCode": { + "type": "string", + }, + "location": { + "type": "string", + }, + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "type": "object", + }, + "type": "array", + }, + }, + "type": "object", + }, + }, + }, + "description": "Invalid request", + }, + "415": { + "content": { + "application/json": { + "schema": { + "properties": { + "errors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + }, + "type": "object", + }, + "type": "array", + }, + }, + "type": "object", + }, + }, + }, + "description": "Unsupported Media Type", + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "description": "Branch of data that does not contain errors", + "nullable": true, + "properties": { + "uploadOptionalFiles": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "uploadOptionalFiles", + ], + "type": "object", + }, + "errors": { + "items": { + "properties": { + "locations": { + "items": { + "properties": { + "column": { + "type": "integer", + }, + "line": { + "type": "integer", + }, + }, + "type": "object", + }, + "type": "array", + }, + "message": { + "type": "string", + }, + "path": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "type": "object", + }, + "type": "array", + }, + }, + "type": "object", + }, + }, + }, + "description": "Internal Server Error", + }, + }, + "x-graphql-operation": "mutation uploadOptionalFiles($id: String!, $files: Uploads) { + uploadOptionalFiles(id: $id, otherFiles: $files) }", }, }, @@ -1195,6 +1587,26 @@ exports[`FormData should fail if a file is too big 1`] = ` } `; +exports[`FormData should fail if a required file is missing 1`] = ` +{ + "data": null, + "errors": [ + { + "locations": [ + { + "column": 3, + "line": 2, + }, + ], + "message": "File missing in the request.", + "path": [ + "uploadAFile", + ], + }, + ], +} +`; + exports[`FormData should fail if a single file is too big 1`] = ` { "data": null, diff --git a/src/__tests__/requests/multipartFormData.spec.ts b/src/__tests__/requests/multipartFormData.spec.ts index 5ff3037..6a613f2 100644 --- a/src/__tests__/requests/multipartFormData.spec.ts +++ b/src/__tests__/requests/multipartFormData.spec.ts @@ -29,6 +29,8 @@ const schema = buildASTSchema(gql` otherFiles: Uploads! coverPicture: Upload! ): [String!]! + uploadOptionalFiles(id: String!, otherFiles: Uploads): [String!]! + uploadOptionalFilesAndFile(id: String!, otherFiles: Uploads, coverPicture: Upload): [String!]! } type Query { @@ -95,6 +97,34 @@ const gqlSchema = addMocksToSchema({ } return result }, + uploadOptionalFiles: async ( + root: unknown, + { otherFiles }: { otherFiles: AsyncGenerator }, + ) => { + const result = [] + + for await (const file of otherFiles) { + result.push(await text(file.createReadStream())) + } + return result + }, + uploadOptionalFilesAndFile: async ( + root: unknown, + { + otherFiles, + coverPicture, + }: { otherFiles: AsyncGenerator; coverPicture: Promise }, + ) => { + const result = [] + + const picture = await coverPicture + + assert(null === picture) + for await (const file of otherFiles) { + result.push(await text(file.createReadStream())) + } + return result + }, }, }, schema: makeExecutableSchema({ @@ -146,6 +176,19 @@ const bridge = createOpenAPIGraphQLBridge({ ) @OAOperation(path: "/upload-arrays-and-single/{id}") { uploadAnArrayOfFilesAndASingleFile(id: $id, otherFiles: $files, coverPicture: $coverImage) } + mutation uploadOptionalFiles( + $id: String! + $files: Uploads @OABody(contentType: MULTIPART_FORM_DATA) + ) @OAOperation(path: "/upload-optional-files/{id}") { + uploadOptionalFiles(id: $id, otherFiles: $files) + } + mutation uploadOptionalFilesAndFile( + $id: String! + $files: Uploads @OABody(contentType: MULTIPART_FORM_DATA) + $coverPicture: Upload @OABody(contentType: MULTIPART_FORM_DATA) + ) @OAOperation(path: "/upload-optional-files-and-file/{id}") { + uploadOptionalFilesAndFile(id: $id, otherFiles: $files, coverPicture: $coverPicture) + } `, }) app.use( @@ -159,7 +202,7 @@ app.use( }) } // Process our multipart request and make sure files resolve - const uploadRequest = await processRequest(request, response, { + const { operations: uploadRequest, parsedDocuments } = await processRequest(request, response, { maxFiles: 5, maxFileSize: 1000, }) @@ -167,7 +210,7 @@ app.use( assert(!Array.isArray(uploadRequest)) return execute({ schema: gqlSchema, - document: parse(uploadRequest.query), + document: parsedDocuments[0], variableValues: { ...variables, ...uploadRequest.variables, @@ -300,4 +343,28 @@ describe('FormData', () => { .expect(500) expect(result.body).toMatchSnapshot() }) + it('should allow optional files', async () => { + const result = await request(app) + .post('/upload-optional-files/5') + .field('some', 'value') + .set('Content-Type', 'multipart/form-data') + .expect(200) + expect(result.body).toMatchSnapshot() + }) + it('should allow optional files and file in combination', async () => { + const result = await request(app) + .post('/upload-optional-files-and-file/5') + .field('some', 'value') + .set('Content-Type', 'multipart/form-data') + .expect(200) + expect(result.body).toMatchSnapshot() + }) + it('should fail if a required file is missing', async () => { + const result = await request(app) + .post('/upload-file/10') + .field('some', 'value') + .set('Content-Type', 'multipart/form-data') + .expect(500) + expect(result.body).toMatchSnapshot() + }) }) diff --git a/src/graphql-upload/Upload.ts b/src/graphql-upload/Upload.ts index cece0a2..476f550 100644 --- a/src/graphql-upload/Upload.ts +++ b/src/graphql-upload/Upload.ts @@ -8,10 +8,10 @@ import { FileUpload } from './processRequest.js' * {@link GraphQLUpload} derives it’s value from {@link Upload.promise}. */ export default class Upload { - promise: Promise - resolve?: (file: FileUpload) => void + promise: Promise + resolve?: (file: FileUpload | null) => void reject?: (error: Error) => void - value?: FileUpload + value?: FileUpload | null constructor() { /** @@ -23,7 +23,7 @@ export default class Upload { * Resolves the upload promise with the file upload details. This should * only be utilized by {@linkcode processRequest}. */ - this.resolve = (file: FileUpload) => { + this.resolve = (file: FileUpload | null) => { /** * The file upload details, available when the * {@linkcode Upload.promise} resolves. This should only be utilized by diff --git a/src/graphql-upload/__tests__/__snapshots__/process-request.spec.ts.snap b/src/graphql-upload/__tests__/__snapshots__/process-request.spec.ts.snap index 4cd1e48..2ed54e1 100644 --- a/src/graphql-upload/__tests__/__snapshots__/process-request.spec.ts.snap +++ b/src/graphql-upload/__tests__/__snapshots__/process-request.spec.ts.snap @@ -1,3 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`graphql-upload-spec \`processRequest\` with a missing multipart form field file. 1`] = `"File missing in the request."`; + +exports[`graphql-upload-spec \`processRequest\` with missing graphql document 1`] = `"Missing ‘query’ field on operations."`; diff --git a/src/graphql-upload/__tests__/__snapshots__/utils.spec.ts.snap b/src/graphql-upload/__tests__/__snapshots__/utils.spec.ts.snap new file mode 100644 index 0000000..04ae737 --- /dev/null +++ b/src/graphql-upload/__tests__/__snapshots__/utils.spec.ts.snap @@ -0,0 +1,254 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getAllVariablesFromDocuments 1`] = ` +{ + "0.variables.var1": { + "defaultValue": undefined, + "directives": [], + "kind": "VariableDefinition", + "loc": { + "end": 34, + "start": 21, + }, + "type": { + "kind": "NamedType", + "loc": { + "end": 34, + "start": 28, + }, + "name": { + "kind": "Name", + "loc": { + "end": 34, + "start": 28, + }, + "value": "Upload", + }, + }, + "variable": { + "kind": "Variable", + "loc": { + "end": 26, + "start": 21, + }, + "name": { + "kind": "Name", + "loc": { + "end": 26, + "start": 22, + }, + "value": "var1", + }, + }, + }, + "0.variables.var2": { + "defaultValue": undefined, + "directives": [], + "kind": "VariableDefinition", + "loc": { + "end": 50, + "start": 36, + }, + "type": { + "kind": "NonNullType", + "loc": { + "end": 50, + "start": 43, + }, + "type": { + "kind": "NamedType", + "loc": { + "end": 49, + "start": 43, + }, + "name": { + "kind": "Name", + "loc": { + "end": 49, + "start": 43, + }, + "value": "Upload", + }, + }, + }, + "variable": { + "kind": "Variable", + "loc": { + "end": 41, + "start": 36, + }, + "name": { + "kind": "Name", + "loc": { + "end": 41, + "start": 37, + }, + "value": "var2", + }, + }, + }, + "0.variables.var3": { + "defaultValue": undefined, + "directives": [], + "kind": "VariableDefinition", + "loc": { + "end": 65, + "start": 52, + }, + "type": { + "kind": "NamedType", + "loc": { + "end": 65, + "start": 59, + }, + "name": { + "kind": "Name", + "loc": { + "end": 65, + "start": 59, + }, + "value": "Upload", + }, + }, + "variable": { + "kind": "Variable", + "loc": { + "end": 57, + "start": 52, + }, + "name": { + "kind": "Name", + "loc": { + "end": 57, + "start": 53, + }, + "value": "var3", + }, + }, + }, + "1.variables.var1": { + "defaultValue": undefined, + "directives": [], + "kind": "VariableDefinition", + "loc": { + "end": 34, + "start": 21, + }, + "type": { + "kind": "NamedType", + "loc": { + "end": 34, + "start": 28, + }, + "name": { + "kind": "Name", + "loc": { + "end": 34, + "start": 28, + }, + "value": "Upload", + }, + }, + "variable": { + "kind": "Variable", + "loc": { + "end": 26, + "start": 21, + }, + "name": { + "kind": "Name", + "loc": { + "end": 26, + "start": 22, + }, + "value": "var1", + }, + }, + }, + "1.variables.var2": { + "defaultValue": undefined, + "directives": [], + "kind": "VariableDefinition", + "loc": { + "end": 50, + "start": 36, + }, + "type": { + "kind": "NonNullType", + "loc": { + "end": 50, + "start": 43, + }, + "type": { + "kind": "NamedType", + "loc": { + "end": 49, + "start": 43, + }, + "name": { + "kind": "Name", + "loc": { + "end": 49, + "start": 43, + }, + "value": "Upload", + }, + }, + }, + "variable": { + "kind": "Variable", + "loc": { + "end": 41, + "start": 36, + }, + "name": { + "kind": "Name", + "loc": { + "end": 41, + "start": 37, + }, + "value": "var2", + }, + }, + }, + "1.variables.var3": { + "defaultValue": undefined, + "directives": [], + "kind": "VariableDefinition", + "loc": { + "end": 65, + "start": 52, + }, + "type": { + "kind": "NamedType", + "loc": { + "end": 65, + "start": 59, + }, + "name": { + "kind": "Name", + "loc": { + "end": 65, + "start": 59, + }, + "value": "Upload", + }, + }, + "variable": { + "kind": "Variable", + "loc": { + "end": 57, + "start": 52, + }, + "name": { + "kind": "Name", + "loc": { + "end": 57, + "start": 53, + }, + "value": "var3", + }, + }, + }, +} +`; diff --git a/src/graphql-upload/__tests__/process-request.spec.ts b/src/graphql-upload/__tests__/process-request.spec.ts index 709f4e7..81a688b 100644 --- a/src/graphql-upload/__tests__/process-request.spec.ts +++ b/src/graphql-upload/__tests__/process-request.spec.ts @@ -21,9 +21,12 @@ describe('graphql-upload-spec', () => { const app = express() app.use(async (req, res) => { const result = await processRequest(req, res) - res.status(200).send(result) + res.status(200).send(result.operations) }) - const operation = { variables: { a: true } } + const operation = { + query: `mutation upload($a: Upload) { uploadFile(file: $a) }`, + variables: { a: true }, + } await request(app) .post('/') @@ -35,10 +38,10 @@ describe('graphql-upload-spec', () => { const app = express() app.use( handleAsyncError(async (req, res) => { - const result = await processRequest(req, res) - assert(!Array.isArray(result)) - if ('promise' in result.variables.file) { - await result.variables.file.promise + const { operations } = await processRequest(req, res) + assert(!Array.isArray(operations)) + if ('promise' in operations.variables.file) { + await operations.variables.file.promise } res.status(200).send(result) }), @@ -46,7 +49,34 @@ describe('graphql-upload-spec', () => { const result = await request(app) .post('/') - .field('operations', JSON.stringify({ variables: { file: null } })) + .field( + 'operations', + JSON.stringify({ + query: `mutation upload($file: Upload!) { uploadFile(file: $a) }`, + variables: { file: null }, + }), + ) + .field('map', JSON.stringify({ 1: ['variables.file'] })) + .expect(400) + expect(result.text).toMatchSnapshot() + }) + + it('`processRequest` with missing graphql document', async () => { + const app = express() + app.use( + handleAsyncError(async (req, res) => { + const result = await processRequest(req, res) + res.status(200).send(result.operations) + }), + ) + const result = await request(app) + .post('/') + .field( + 'operations', + JSON.stringify({ + variables: { file: null }, + }), + ) .field('map', JSON.stringify({ 1: ['variables.file'] })) .expect(400) expect(result.text).toMatchSnapshot() diff --git a/src/graphql-upload/__tests__/utils.spec.ts b/src/graphql-upload/__tests__/utils.spec.ts new file mode 100644 index 0000000..0b309f5 --- /dev/null +++ b/src/graphql-upload/__tests__/utils.spec.ts @@ -0,0 +1,14 @@ +import { parse } from 'graphql' +import { getAllVariablesFromDocuments } from '../utils.js' + +test('getAllVariablesFromDocuments', () => { + const document1 = parse(` + mutation upload($var1: Upload, $var2: Upload!, $var3: Upload) { + upload(var1: $var1, var2: $var2, var3: $var3) + } + `) + + const result = getAllVariablesFromDocuments([document1, document1]) + + expect(result).toMatchSnapshot() +}) diff --git a/src/graphql-upload/expressMiddleware.ts b/src/graphql-upload/expressMiddleware.ts index b65fa57..32060f5 100644 --- a/src/graphql-upload/expressMiddleware.ts +++ b/src/graphql-upload/expressMiddleware.ts @@ -22,8 +22,8 @@ export function graphqlUploadExpress(processRequestOptions?: Parameters { - request.body = body + .then(({ operations }) => { + request.body = operations next() }) .catch((error) => { diff --git a/src/graphql-upload/processRequest.ts b/src/graphql-upload/processRequest.ts index 3b29b17..9fe91d4 100644 --- a/src/graphql-upload/processRequest.ts +++ b/src/graphql-upload/processRequest.ts @@ -8,6 +8,9 @@ import Upload from './Upload.js' import { IncomingMessage, OutgoingMessage } from 'node:http' import { AsyncQueue } from '../iterable.js' import _ from 'lodash' +import { Kind, parse } from 'graphql' +import type { DocumentNode } from 'graphql/language/ast.js' +import { getAllVariablesFromDocuments } from './utils.js' const GRAPHQL_MULTIPART_REQUEST_SPEC_URL = 'https://github.com/jaydenseric/graphql-multipart-request-spec' @@ -44,311 +47,350 @@ export function processRequest( maxFiles = Infinity, } = {}, ) { - return new Promise((resolve, reject) => { - let released: boolean + return new Promise<{ operations: Operation | Operation[]; parsedDocuments: DocumentNode[] }>( + (resolve, reject) => { + let released: boolean - let exitError: Error + let exitError: Error - let operations: Operation | Operation[] + let operations: Operation | Operation[] - let map: Map> + let parsedDocuments: DocumentNode[] - const parser = busboy({ - headers: request.headers, - defParamCharset: 'utf8', - limits: { - fieldSize: maxFieldSize, - fields: 2, // Only operations and map. - fileSize: maxFileSize, - files: maxFiles, - }, - }) + let parsedMap: Record - /** - * Exits request processing with an error. Successive calls have no effect. - */ - function exit(error: Error, isParserError = false) { - if (exitError) return + let map: Map> - exitError = error + const parser = busboy({ + headers: request.headers, + defParamCharset: 'utf8', + limits: { + fieldSize: maxFieldSize, + fields: 2, // Only operations and map. + fileSize: maxFileSize, + files: maxFiles, + }, + }) - if (map) - for (const upload of map.values()) { - if (upload instanceof AsyncQueue) { - upload.reject(exitError) - } - if (upload instanceof Upload) { - if (!upload.value) { - upload.reject?.(exitError) + /** + * Exits request processing with an error. Successive calls have no effect. + */ + function exit(error: Error, isParserError = false) { + if (exitError) return + + exitError = error + + if (map) + for (const upload of map.values()) { + if (upload instanceof AsyncQueue) { + upload.reject(exitError) + } + if (upload instanceof Upload) { + if (!upload.value) { + upload.reject?.(exitError) + } } } - } - // If the error came from the parser, don’t cause it to be emitted again. - isParserError ? parser.destroy() : parser.destroy(exitError) + // If the error came from the parser, don’t cause it to be emitted again. + isParserError ? parser.destroy() : parser.destroy(exitError) - request.unpipe(parser) + request.unpipe(parser) - // With a sufficiently large request body, subsequent events in the same - // event frame cause the stream to pause after the parser is destroyed. To - // ensure that the request resumes, the call to .resume() is scheduled for - // later in the event loop. - setImmediate(() => { - request.resume() - }) + // With a sufficiently large request body, subsequent events in the same + // event frame cause the stream to pause after the parser is destroyed. To + // ensure that the request resumes, the call to .resume() is scheduled for + // later in the event loop. + setImmediate(() => { + request.resume() + }) - reject(exitError) - } + reject(exitError) + } - parser.on('field', (fieldName, value, { valueTruncated }) => { - if (valueTruncated) - return exit( - createError( - 413, - `The ‘${fieldName}’ multipart field value exceeds the ${maxFieldSize} byte size limit.`, - ), - ) - - switch (fieldName) { - case 'operations': - try { - operations = JSON.parse(value) - } catch (error) { - return exit( - createError( - 400, - `Invalid JSON in the ‘operations’ multipart field (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, - ), - ) - } + parser.on('field', (fieldName, value, { valueTruncated }) => { + if (valueTruncated) + return exit( + createError( + 413, + `The ‘${fieldName}’ multipart field value exceeds the ${maxFieldSize} byte size limit.`, + ), + ) + + switch (fieldName) { + case 'operations': + try { + operations = JSON.parse(value) + } catch (error) { + return exit( + createError( + 400, + `Invalid JSON in the ‘operations’ multipart field (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, + ), + ) + } - // `operations` should be an object or an array. Note that arrays - // and `null` have an `object` type. - if (typeof operations !== 'object' || !operations) - return exit( - createError( - 400, - `Invalid type for the ‘operations’ multipart field (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, - ), - ) - - break - case 'map': { - if (!operations) - return exit( - createError( - 400, - `Misordered multipart fields; ‘map’ should follow ‘operations’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, - ), - ) - - let parsedMap - try { - parsedMap = JSON.parse(value) - } catch (error) { - return exit( - createError( - 400, - `Invalid JSON in the ‘map’ multipart field (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, - ), - ) - } + // `operations` should be an object or an array. Note that arrays + // and `null` have an `object` type. + if (typeof operations !== 'object' || !operations) + return exit( + createError( + 400, + `Invalid type for the ‘operations’ multipart field (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, + ), + ) - // `map` should be an object. - if (typeof parsedMap !== 'object' || !parsedMap || Array.isArray(parsedMap)) - return exit( - createError( - 400, - `Invalid type for the ‘map’ multipart field (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, - ), - ) - - const mapEntries = Object.entries(parsedMap) - - // Check max files is not exceeded, even though the number of files - // to parse might not match the map provided by the client. - if (mapEntries.length > maxFiles) - return exit(createError(413, `${maxFiles} max file uploads exceeded.`)) - - map = new Map() - for (const [fieldName, paths] of mapEntries) { - if (!Array.isArray(paths)) + try { + const ops = Array.isArray(operations) ? operations : [operations] + parsedDocuments = ops + .filter((op) => typeof op.query !== 'undefined') + .map((op) => parse(op.query)) + } catch (e) { return exit( createError( 400, - `Invalid type for the ‘map’ multipart field entry key ‘${fieldName}’ array (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, + `Unable to parse graphql documents on ‘operations’: ${(e as Error).message}`, ), ) + } - const isArray = fieldName.includes('[]') - const thisFieldName = isArray ? fieldName.replace('[]', '') : fieldName + if (parsedDocuments.length === 0) { + return exit(createError(400, `Missing ‘query’ field on operations.`)) + } - map.set(thisFieldName, isArray ? new AsyncQueue() : new Upload()) + break + case 'map': { + if (!operations) + return exit( + createError( + 400, + `Misordered multipart fields; ‘map’ should follow ‘operations’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, + ), + ) - for (const [index, path] of paths.entries()) { - if (typeof path !== 'string') - return exit( - createError( - 400, - `Invalid type for the ‘map’ multipart field entry key ‘${thisFieldName}’ array index ‘${index}’ value (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, - ), - ) + try { + parsedMap = JSON.parse(value) + } catch (error) { + return exit( + createError( + 400, + `Invalid JSON in the ‘map’ multipart field (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, + ), + ) + } - try { - _.set(operations, path, map.get(thisFieldName)) - } catch (error) { + // `map` should be an object. + if (typeof parsedMap !== 'object' || !parsedMap || Array.isArray(parsedMap)) + return exit( + createError( + 400, + `Invalid type for the ‘map’ multipart field (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, + ), + ) + + const mapEntries = Object.entries(parsedMap) + + // Check max files is not exceeded, even though the number of files + // to parse might not match the map provided by the client. + if (mapEntries.length > maxFiles) + return exit(createError(413, `${maxFiles} max file uploads exceeded.`)) + + map = new Map() + for (const [fieldName, paths] of mapEntries) { + if (!Array.isArray(paths)) return exit( createError( 400, - `Invalid object path for the ‘map’ multipart field entry key ‘${thisFieldName}’ array index ‘${index}’ value ‘${path}’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, + `Invalid type for the ‘map’ multipart field entry key ‘${fieldName}’ array (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, ), ) + + const isArray = fieldName.includes('[]') + const thisFieldName = isArray ? fieldName.replace('[]', '') : fieldName + + map.set(thisFieldName, isArray ? new AsyncQueue() : new Upload()) + + for (const [index, path] of paths.entries()) { + if (typeof path !== 'string') + return exit( + createError( + 400, + `Invalid type for the ‘map’ multipart field entry key ‘${thisFieldName}’ array index ‘${index}’ value (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, + ), + ) + + try { + _.set(operations, path, map.get(thisFieldName)) + } catch (error) { + return exit( + createError( + 400, + `Invalid object path for the ‘map’ multipart field entry key ‘${thisFieldName}’ array index ‘${index}’ value ‘${path}’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, + ), + ) + } } } + + resolve({ operations, parsedDocuments }) } + } + }) - resolve(operations) + parser.on('file', (fieldName, stream, { filename, encoding, mimeType: mimetype }) => { + if (!map) { + ignoreStream(stream) + return exit( + createError( + 400, + `Misordered multipart fields; files should follow ‘map’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, + ), + ) } - } - }) - - parser.on('file', (fieldName, stream, { filename, encoding, mimeType: mimetype }) => { - if (!map) { - ignoreStream(stream) - return exit( - createError( - 400, - `Misordered multipart fields; files should follow ‘map’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, - ), - ) - } - const upload = map.get(fieldName) + const upload = map.get(fieldName) - if (!upload) { - // The file is extraneous. As the rest can still be processed, just - // ignore it and don’t exit with an error. - ignoreStream(stream) - return - } + if (!upload) { + // The file is extraneous. As the rest can still be processed, just + // ignore it and don’t exit with an error. + ignoreStream(stream) + return + } - let fileError: Error + let fileError: Error - const capacitor = new WriteStream() + const capacitor = new WriteStream() - capacitor.on('error', () => { - stream.unpipe() - stream.resume() - }) + capacitor.on('error', () => { + stream.unpipe() + stream.resume() + }) - stream.on('limit', () => { - fileError = createError( - 413, - `File truncated as it exceeds the ${maxFileSize} byte size limit.`, - ) - stream.unpipe() - capacitor.destroy(fileError) - }) + stream.on('limit', () => { + fileError = createError( + 413, + `File truncated as it exceeds the ${maxFileSize} byte size limit.`, + ) + stream.unpipe() + capacitor.destroy(fileError) + }) + + stream.on('error', (error) => { + fileError = error + stream.unpipe() + capacitor.destroy(fileError) + }) + + const file: FileUpload = { + filename, + mimetype, + encoding, + createReadStream(options?: ReadStreamOptions) { + const error = fileError || (released ? exitError : null) + if (error) throw error + return capacitor.createReadStream(options) + }, + capacitor, + } - stream.on('error', (error) => { - fileError = error - stream.unpipe() - capacitor.destroy(fileError) - }) + Object.defineProperty(file, 'capacitor', { + enumerable: false, + configurable: false, + writable: false, + }) - const file: FileUpload = { - filename, - mimetype, - encoding, - createReadStream(options?: ReadStreamOptions) { - const error = fileError || (released ? exitError : null) - if (error) throw error - return capacitor.createReadStream(options) - }, - capacitor, - } + stream.pipe(capacitor) - Object.defineProperty(file, 'capacitor', { - enumerable: false, - configurable: false, - writable: false, + if (upload instanceof AsyncQueue) { + upload.add(file) + return + } + upload.resolve?.(file) }) - stream.pipe(capacitor) + parser.once('filesLimit', () => + exit(createError(413, `${maxFiles} max file uploads exceeded.`)), + ) - if (upload instanceof AsyncQueue) { - upload.add(file) - return - } - upload.resolve?.(file) - }) - - parser.once('filesLimit', () => - exit(createError(413, `${maxFiles} max file uploads exceeded.`)), - ) - - parser.once('finish', () => { - request.unpipe(parser) - request.resume() - - if (!operations) - return exit( - createError( - 400, - `Missing multipart field ‘operations’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, - ), - ) - - if (!map) - return exit( - createError( - 400, - `Missing multipart field ‘map’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, - ), - ) - - for (const upload of map.values()) { - if (upload instanceof AsyncQueue) { - upload.terminate() - } - if (upload instanceof Upload) { - if (!upload.value) { - upload.reject?.(createError(400, 'File missing in the request.')) - } - } - } - }) - - // Use the `on` method instead of `once` as in edge cases the same parser - // could have multiple `error` events and all must be handled to prevent the - // Node.js process exiting with an error. One edge case is if there is a - // malformed part header as well as an unexpected end of the form. - parser.on('error', (error: Error) => { - exit(error, true) - }) - - response.once('close', () => { - released = true - if (map) - for (const upload of map.values()) { + parser.once('finish', () => { + request.unpipe(parser) + request.resume() + + if (!operations) + return exit( + createError( + 400, + `Missing multipart field ‘operations’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, + ), + ) + + if (!map) + return exit( + createError( + 400, + `Missing multipart field ‘map’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`, + ), + ) + + const allVariables = getAllVariablesFromDocuments(parsedDocuments) + + for (const [fieldName, upload] of map.entries()) { if (upload instanceof AsyncQueue) { - upload.processedItems.forEach((item) => { - item.capacitor.release() - }) - } else { - if (upload.value) { - // Release resources and clean up temporary files. - upload.value.capacitor.release() + upload.terminate() + } + if (upload instanceof Upload) { + if (!upload.value) { + const variableDefinitions = parsedMap[fieldName] + .map((path, index) => { + return allVariables[`${index}.${path}`] + }) + .filter(Boolean) + + if ( + variableDefinitions.find((def) => { + return def.type.kind === Kind.NON_NULL_TYPE + }) + ) { + upload.reject?.(createError(400, 'File missing in the request.')) + continue + } + upload.resolve?.(null) } } } - }) + }) - request.once('close', () => { - if (!request.readableEnded) - exit(createError(499, 'Request disconnected during file upload stream parsing.')) - }) + // Use the `on` method instead of `once` as in edge cases the same parser + // could have multiple `error` events and all must be handled to prevent the + // Node.js process exiting with an error. One edge case is if there is a + // malformed part header as well as an unexpected end of the form. + parser.on('error', (error: Error) => { + exit(error, true) + }) + + response.once('close', () => { + released = true + if (map) + for (const upload of map.values()) { + if (upload instanceof AsyncQueue) { + upload.processedItems.forEach((item) => { + item.capacitor.release() + }) + } else { + if (upload.value) { + // Release resources and clean up temporary files. + upload.value.capacitor.release() + } + } + } + }) + + request.once('close', () => { + if (!request.readableEnded) + exit(createError(499, 'Request disconnected during file upload stream parsing.')) + }) - request.pipe(parser) - }) + request.pipe(parser) + }, + ) } diff --git a/src/graphql-upload/utils.ts b/src/graphql-upload/utils.ts new file mode 100644 index 0000000..57b95e1 --- /dev/null +++ b/src/graphql-upload/utils.ts @@ -0,0 +1,14 @@ +import { OperationDefinitionNode } from 'graphql/index.js' +import { DocumentNode } from 'graphql' + +export const getAllVariablesFromDocuments = (docs: DocumentNode[]) => { + return Object.fromEntries( + docs.flatMap((document, index) => { + return (document.definitions as OperationDefinitionNode[]).flatMap((op) => { + return (op.variableDefinitions || []).map((v) => { + return [`${index}.variables.${v.variable.name.value}`, v] + }) + }) + }), + ) +}