From 718812a7bbeaed08489acb0c38c88730322d7eeb Mon Sep 17 00:00:00 2001 From: Dylan Wulf Date: Thu, 2 Apr 2020 15:45:46 -0400 Subject: [PATCH 01/10] Transform uploads to Streams before calling action --- index.js | 3 ++- src/service.js | 29 +++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index b5e2c23..315390a 100644 --- a/index.js +++ b/index.js @@ -15,13 +15,14 @@ "use strict"; const core = require("apollo-server-core"); +const { GraphQLUpload } = require("graphql-upload"); const { ApolloServer } = require("./src/ApolloServer"); const ApolloService = require("./src/service"); const gql = require("./src/gql"); module.exports = { // Core - GraphQLUpload: core.GraphQLUpload, + GraphQLUpload: GraphQLUpload, GraphQLExtension: core.GraphQLExtension, gql: core.gql, ApolloError: core.ApolloError, diff --git a/src/service.js b/src/service.js index b69952a..6d0af0b 100644 --- a/src/service.js +++ b/src/service.js @@ -133,6 +133,7 @@ module.exports = function(mixinOptions) { nullIfError = false, params: staticParams = {}, rootParams = {}, + fileUploadArg = null, } = def; const rootKeys = Object.keys(rootParams); @@ -189,6 +190,27 @@ module.exports = function(mixinOptions) { return Array.isArray(dataLoaderKey) ? await dataLoader.loadMany(dataLoaderKey) : await dataLoader.load(dataLoaderKey); + } else if (fileUploadArg != null && args[fileUploadArg] != null) { + if (Array.isArray(args[fileUploadArg])) { + return await Promise.all( + args[fileUploadArg].map(async uploadPromise => { + const { + createReadStream, + ...$fileInfo + } = await uploadPromise; + const stream = createReadStream(); + return await context.ctx.call(actionName, stream, { + meta: { $fileInfo }, + }); + }) + ); + } + + const { createReadStream, ...$fileInfo } = await args[fileUploadArg]; + const stream = createReadStream(); + return await context.ctx.call(actionName, stream, { + meta: { $fileInfo }, + }); } else { const params = {}; if (root && rootKeys) { @@ -390,7 +412,7 @@ module.exports = function(mixinOptions) { const resolver = {}; Object.values(service.actions).forEach(action => { - const { graphql: def } = action; + const { graphql: def, fileUploadArg } = action; if (def && _.isObject(def)) { if (def.query) { if (!resolver["Query"]) resolver.Query = {}; @@ -411,7 +433,10 @@ module.exports = function(mixinOptions) { const name = this.getFieldName(mutation); mutations.push(mutation); resolver.Mutation[name] = this.createActionResolver( - action.name + action.name, + { + fileUploadArg, + } ); }); } From c8ffbd82f6a0e847fb79dced2e32a2d60691effe Mon Sep 17 00:00:00 2001 From: Dylan Wulf Date: Fri, 3 Apr 2020 11:26:40 -0400 Subject: [PATCH 02/10] Remove redundant await --- src/service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/service.js b/src/service.js index 6d0af0b..750b5a1 100644 --- a/src/service.js +++ b/src/service.js @@ -199,7 +199,7 @@ module.exports = function(mixinOptions) { ...$fileInfo } = await uploadPromise; const stream = createReadStream(); - return await context.ctx.call(actionName, stream, { + return context.ctx.call(actionName, stream, { meta: { $fileInfo }, }); }) From d280fd9288538e166b21ce8846f03f3f60817a3f Mon Sep 17 00:00:00 2001 From: Dylan Wulf Date: Fri, 3 Apr 2020 11:34:28 -0400 Subject: [PATCH 03/10] Move fileUploadArg inside of graphql property --- src/service.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/service.js b/src/service.js index 750b5a1..32b07d5 100644 --- a/src/service.js +++ b/src/service.js @@ -412,7 +412,7 @@ module.exports = function(mixinOptions) { const resolver = {}; Object.values(service.actions).forEach(action => { - const { graphql: def, fileUploadArg } = action; + const { graphql: def } = action; if (def && _.isObject(def)) { if (def.query) { if (!resolver["Query"]) resolver.Query = {}; @@ -435,7 +435,7 @@ module.exports = function(mixinOptions) { resolver.Mutation[name] = this.createActionResolver( action.name, { - fileUploadArg, + fileUploadArg: def.fileUploadArg, } ); }); From 147b47afe5ea5dc2c4817baa961485fc5a2b38f4 Mon Sep 17 00:00:00 2001 From: Dylan Wulf Date: Fri, 3 Apr 2020 12:59:27 -0400 Subject: [PATCH 04/10] Update TS type for GraphQLUpload scalar --- index.d.ts | 3 ++- package.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index 111b234..3c5136e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,7 +5,6 @@ declare module "moleculer-apollo-server" { import { SchemaDirectiveVisitor, IResolvers } from "graphql-tools"; export { - GraphQLUpload, GraphQLExtension, gql, ApolloError, @@ -18,6 +17,8 @@ declare module "moleculer-apollo-server" { defaultPlaygroundOptions, } from "apollo-server-core"; + export { GraphQLUpload } from 'graphql-upload'; + export * from "graphql-tools"; export interface ApolloServerOptions { diff --git a/package.json b/package.json index 1c339b6..e0fb114 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "dependencies": { "@apollographql/graphql-playground-html": "^1.6.24", "@hapi/accept": "^3.2.4", + "@types/graphql-upload": "^8.0.0", "apollo-server-core": "^2.10.0", "dataloader": "^2.0.0", "graphql-subscriptions": "^1.1.0", From fc16928dd3a532caa3a9c20bf705bd383015a5ed Mon Sep 17 00:00:00 2001 From: Dylan Wulf Date: Fri, 3 Apr 2020 15:13:30 -0400 Subject: [PATCH 05/10] Add unit tests for file uploads --- test/unit/service.spec.js | 101 +++++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/test/unit/service.spec.js b/test/unit/service.spec.js index ccf4399..e915129 100644 --- a/test/unit/service.spec.js +++ b/test/unit/service.spec.js @@ -436,7 +436,7 @@ describe("Test Service", () => { }); }); - describe("Test 'createActionResolver' without DataLoader", () => { + describe("Test 'createActionResolver' without DataLoader or Upload", () => { let broker, svc, stop; beforeAll(async () => { @@ -534,6 +534,105 @@ describe("Test Service", () => { }); }); + describe("Test 'createActionResolver' with File Upload", () => { + let broker, svc, stop; + + beforeAll(async () => { + const res = await startService(); + broker = res.broker; + svc = res.svc; + stop = res.stop; + }); + + afterAll(async () => await stop()); + + it("should create a stream and pass to call", async () => { + const resolver = svc.createActionResolver("posts.uploadSingle", { + fileUploadArg: "file", + }); + + const ctx = new Context(broker); + ctx.call = jest.fn(() => "response from action"); + + const fakeRoot = {}; + + const file = { + filename: "filename.txt", + encoding: "7bit", + mimetype: "text/plain", + createReadStream: () => "fake read stream", + }; + + const res = await resolver(fakeRoot, { file }, { ctx }); + + expect(res).toBe("response from action"); + + expect(ctx.call).toBeCalledTimes(1); + expect(ctx.call).toBeCalledWith("posts.uploadSingle", "fake read stream", { + meta: { + $fileInfo: { + filename: "filename.txt", + encoding: "7bit", + mimetype: "text/plain", + }, + }, + }); + }); + + it("should invoke call once per file when handling an array of file uploads", async () => { + const resolver = svc.createActionResolver("posts.uploadMulti", { + fileUploadArg: "files", + }); + + const ctx = new Context(broker); + ctx.call = jest.fn((_, stream) => `response for ${stream}`); + + const fakeRoot = {}; + + const files = [ + { + filename: "filename1.txt", + encoding: "7bit", + mimetype: "text/plain", + createReadStream: () => "fake read stream 1", + }, + { + filename: "filename2.txt", + encoding: "7bit", + mimetype: "text/plain", + createReadStream: () => "fake read stream 2", + }, + ]; + + const res = await resolver(fakeRoot, { files }, { ctx }); + + expect(res).toEqual([ + "response for fake read stream 1", + "response for fake read stream 2", + ]); + + expect(ctx.call).toBeCalledTimes(2); + expect(ctx.call).toBeCalledWith("posts.uploadMulti", "fake read stream 1", { + meta: { + $fileInfo: { + filename: "filename1.txt", + encoding: "7bit", + mimetype: "text/plain", + }, + }, + }); + expect(ctx.call).toBeCalledWith("posts.uploadMulti", "fake read stream 2", { + meta: { + $fileInfo: { + filename: "filename2.txt", + encoding: "7bit", + mimetype: "text/plain", + }, + }, + }); + }); + }); + describe("Test 'createActionResolver' with DataLoader", () => { let broker, svc, stop; From 981a89a943dd2340bf67b0967515f7f6342de192 Mon Sep 17 00:00:00 2001 From: Dylan Wulf Date: Fri, 3 Apr 2020 16:40:49 -0400 Subject: [PATCH 06/10] Add documentation for file uploads --- README.md | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/README.md b/README.md index 71efbda..7d82b83 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,107 @@ module.exports = { }; ``` +### File Uploads +moleculer-apollo-server supports file uploads through the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). + +To enable uploads, the Upload scalar must be added to the Gateway: + +```js +"use strict"; + +const ApiGateway = require("moleculer-web"); +const { ApolloService, GraphQLUpload } = require("moleculer-apollo-server"); + +module.exports = { + name: "api", + + mixins: [ + // Gateway + ApiGateway, + + // GraphQL Apollo Server + ApolloService({ + + // Global GraphQL typeDefs + typeDefs: ["scalar Upload"], + + // Global resolvers + resolvers: { + Upload: GraphQLUpload + }, + + // API Gateway route options + routeOptions: { + path: "/graphql", + cors: true, + mappingPolicy: "restrict" + }, + + // https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html + serverOptions: { + tracing: true, + + engine: { + apiKey: process.env.APOLLO_ENGINE_KEY + } + } + }) + ] +}; + +``` + +Then a mutation can be created which accepts an Upload argument. The `fileUploadArg` property must be set to the mutation's argument name so that moleculer-apollo-server knows where to expect a file upload. When the mutation's action handler is called, `ctx.params` will be a [Readable Stream](https://nodejs.org/api/stream.html#stream_readable_streams) which can be used to read the contents of the uploaded file (or pipe the contents into a Writable Stream). File metadata will be made available in `ctx.meta.$fileInfo`. + +**files.service.js** +```js +module.exports = { + name: "files", + settings: { + graphql: { + type: ` + """ + This type describes a File entity. + """ + type File { + filename: String! + encoding: String! + mimetype: String! + } + ` + } + }, + actions: { + uploadFile: { + graphql: { + mutation: "uploadFile(file: Upload!): File!", + fileUploadArg: "file", + }, + async handler(ctx) { + const fileChunks = []; + for await (const chunk of ctx.params) { + fileChunks.push(chunk); + } + const fileContents = Buffer.concat(fileChunks); + // Do something with file contents + return ctx.params.$fileInfo; + } + } + } +}; +``` + +To accept multiple uploaded files in a single request, the mutation can be changed to accept an array of `Upload`s and return an array of results. The action handler will then be called once for each uploaded file, and the results will be combined into an array automatically with results in the same order as the provided files. + +```js +... +graphql: { + mutation: "upload(file: [Upload!]!): [File!]!", + fileUploadArg: "file" +} +... +``` + ### Dataloader moleculer-apollo-server supports [DataLoader](https://github.com/graphql/dataloader) via configuration in the resolver definition. The called action must be compatible with DataLoader semantics -- that is, it must accept params with an array property and return an array of the same size, From 578a1c38f3db3464640472f7de593520ea80069a Mon Sep 17 00:00:00 2001 From: Dylan Wulf Date: Fri, 3 Apr 2020 17:23:38 -0400 Subject: [PATCH 07/10] Fix mistake in readme code example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d82b83..79b77b3 100644 --- a/README.md +++ b/README.md @@ -341,7 +341,7 @@ module.exports = { } const fileContents = Buffer.concat(fileChunks); // Do something with file contents - return ctx.params.$fileInfo; + return ctx.meta.$fileInfo; } } } From e7d187bcecced7bfb1a1a87f9c00e395e69ce84e Mon Sep 17 00:00:00 2001 From: Dylan Wulf Date: Fri, 3 Apr 2020 17:28:36 -0400 Subject: [PATCH 08/10] Create file upload example --- README.md | 3 ++ examples/upload/index.js | 95 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 examples/upload/index.js diff --git a/README.md b/README.md index 79b77b3..987171b 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,9 @@ It is unlikely that setting any of the options which accept a function will work - [Simple](examples/simple/index.js) - `npm run dev` +- [File Upload](examples/upload/index.js) + - `npm run dev upload` + - See [here](https://github.com/jaydenseric/graphql-multipart-request-spec#curl-request) for information about how to create a file upload request - [Full](examples/full/index.js) - `npm run dev full` - [Full With Dataloader](examples/full/index.js) diff --git a/examples/upload/index.js b/examples/upload/index.js new file mode 100644 index 0000000..113af3c --- /dev/null +++ b/examples/upload/index.js @@ -0,0 +1,95 @@ +"use strict"; + +const { ServiceBroker } = require("moleculer"); + +const ApiGateway = require("moleculer-web"); +const { ApolloService, GraphQLUpload } = require("../../index"); + +const broker = new ServiceBroker({ logLevel: "info", hotReload: true }); + +broker.createService({ + name: "api", + + mixins: [ + // Gateway + ApiGateway, + + // GraphQL Apollo Server + ApolloService({ + typeDefs: ["scalar Upload"], + resolvers: { + Upload: GraphQLUpload, + }, + // API Gateway route options + routeOptions: { + path: "/graphql", + cors: true, + mappingPolicy: "restrict", + }, + + // https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html + serverOptions: {}, + }), + ], + + events: { + "graphql.schema.updated"({ schema }) { + this.logger.info("Generated GraphQL schema:\n\n" + schema); + }, + }, +}); + +broker.createService({ + name: "files", + settings: { + graphql: { + type: ` + """ + This type describes a File entity. + """ + type File { + filename: String! + encoding: String! + mimetype: String! + } + `, + }, + }, + actions: { + hello: { + graphql: { + query: "hello: String!", + }, + handler() { + return "Hello Moleculer!"; + }, + }, + singleUpload: { + graphql: { + mutation: "singleUpload(file: Upload!): File!", + fileUploadArg: "file", + }, + async handler(ctx) { + const fileChunks = []; + for await (const chunk of ctx.params) { + fileChunks.push(chunk); + } + const fileContents = Buffer.concat(fileChunks); + ctx.broker.logger.info("Uploaded File Contents:"); + ctx.broker.logger.info(fileContents.toString()); + return ctx.meta.$fileInfo; + }, + }, + }, +}); + +broker.start().then(async () => { + broker.repl(); + + broker.logger.info("----------------------------------------------------------"); + broker.logger.info("For information about creating a file upload request,"); + broker.logger.info( + "see https://github.com/jaydenseric/graphql-multipart-request-spec#curl-request" + ); + broker.logger.info("----------------------------------------------------------"); +}); From a059a0fcab6258c19eb10f697995ba0974cbc859 Mon Sep 17 00:00:00 2001 From: Dylan Wulf Date: Fri, 3 Apr 2020 17:43:52 -0400 Subject: [PATCH 09/10] Simplify export syntax --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 315390a..b541dec 100644 --- a/index.js +++ b/index.js @@ -22,7 +22,7 @@ const gql = require("./src/gql"); module.exports = { // Core - GraphQLUpload: GraphQLUpload, + GraphQLUpload, GraphQLExtension: core.GraphQLExtension, gql: core.gql, ApolloError: core.ApolloError, From 24e9d8bc1baa832e6424133854730d33410a12f2 Mon Sep 17 00:00:00 2001 From: Dylan Wulf Date: Fri, 3 Apr 2020 18:44:56 -0400 Subject: [PATCH 10/10] Move GraphQLUpload export to different section in exports --- index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index b541dec..2dbc4cd 100644 --- a/index.js +++ b/index.js @@ -22,7 +22,6 @@ const gql = require("./src/gql"); module.exports = { // Core - GraphQLUpload, GraphQLExtension: core.GraphQLExtension, gql: core.gql, ApolloError: core.ApolloError, @@ -37,6 +36,9 @@ module.exports = { // GraphQL tools ...require("graphql-tools"), + // GraphQL Upload + GraphQLUpload, + // Apollo Server ApolloServer,