Skip to content

Commit e89ec85

Browse files
authored
Transform Uploads to Streams before calling action (#71)
Update the upload feature to transform object resolved from GraphQL upload to a stream before calling the resolution action. This allows uploads to work properly across the transporter.
1 parent fcef73c commit e89ec85

7 files changed

Lines changed: 332 additions & 4 deletions

File tree

README.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,107 @@ module.exports = {
258258
};
259259
```
260260

261+
### File Uploads
262+
moleculer-apollo-server supports file uploads through the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec).
263+
264+
To enable uploads, the Upload scalar must be added to the Gateway:
265+
266+
```js
267+
"use strict";
268+
269+
const ApiGateway = require("moleculer-web");
270+
const { ApolloService, GraphQLUpload } = require("moleculer-apollo-server");
271+
272+
module.exports = {
273+
name: "api",
274+
275+
mixins: [
276+
// Gateway
277+
ApiGateway,
278+
279+
// GraphQL Apollo Server
280+
ApolloService({
281+
282+
// Global GraphQL typeDefs
283+
typeDefs: ["scalar Upload"],
284+
285+
// Global resolvers
286+
resolvers: {
287+
Upload: GraphQLUpload
288+
},
289+
290+
// API Gateway route options
291+
routeOptions: {
292+
path: "/graphql",
293+
cors: true,
294+
mappingPolicy: "restrict"
295+
},
296+
297+
// https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html
298+
serverOptions: {
299+
tracing: true,
300+
301+
engine: {
302+
apiKey: process.env.APOLLO_ENGINE_KEY
303+
}
304+
}
305+
})
306+
]
307+
};
308+
309+
```
310+
311+
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`.
312+
313+
**files.service.js**
314+
```js
315+
module.exports = {
316+
name: "files",
317+
settings: {
318+
graphql: {
319+
type: `
320+
"""
321+
This type describes a File entity.
322+
"""
323+
type File {
324+
filename: String!
325+
encoding: String!
326+
mimetype: String!
327+
}
328+
`
329+
}
330+
},
331+
actions: {
332+
uploadFile: {
333+
graphql: {
334+
mutation: "uploadFile(file: Upload!): File!",
335+
fileUploadArg: "file",
336+
},
337+
async handler(ctx) {
338+
const fileChunks = [];
339+
for await (const chunk of ctx.params) {
340+
fileChunks.push(chunk);
341+
}
342+
const fileContents = Buffer.concat(fileChunks);
343+
// Do something with file contents
344+
return ctx.meta.$fileInfo;
345+
}
346+
}
347+
}
348+
};
349+
```
350+
351+
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.
352+
353+
```js
354+
...
355+
graphql: {
356+
mutation: "upload(file: [Upload!]!): [File!]!",
357+
fileUploadArg: "file"
358+
}
359+
...
360+
```
361+
261362
### Dataloader
262363
moleculer-apollo-server supports [DataLoader](https://github.com/graphql/dataloader) via configuration in the resolver definition.
263364
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,
@@ -312,6 +413,9 @@ It is unlikely that setting any of the options which accept a function will work
312413
313414
- [Simple](examples/simple/index.js)
314415
- `npm run dev`
416+
- [File Upload](examples/upload/index.js)
417+
- `npm run dev upload`
418+
- See [here](https://github.com/jaydenseric/graphql-multipart-request-spec#curl-request) for information about how to create a file upload request
315419
- [Full](examples/full/index.js)
316420
- `npm run dev full`
317421
- [Full With Dataloader](examples/full/index.js)

examples/upload/index.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"use strict";
2+
3+
const { ServiceBroker } = require("moleculer");
4+
5+
const ApiGateway = require("moleculer-web");
6+
const { ApolloService, GraphQLUpload } = require("../../index");
7+
8+
const broker = new ServiceBroker({ logLevel: "info", hotReload: true });
9+
10+
broker.createService({
11+
name: "api",
12+
13+
mixins: [
14+
// Gateway
15+
ApiGateway,
16+
17+
// GraphQL Apollo Server
18+
ApolloService({
19+
typeDefs: ["scalar Upload"],
20+
resolvers: {
21+
Upload: GraphQLUpload,
22+
},
23+
// API Gateway route options
24+
routeOptions: {
25+
path: "/graphql",
26+
cors: true,
27+
mappingPolicy: "restrict",
28+
},
29+
30+
// https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html
31+
serverOptions: {},
32+
}),
33+
],
34+
35+
events: {
36+
"graphql.schema.updated"({ schema }) {
37+
this.logger.info("Generated GraphQL schema:\n\n" + schema);
38+
},
39+
},
40+
});
41+
42+
broker.createService({
43+
name: "files",
44+
settings: {
45+
graphql: {
46+
type: `
47+
"""
48+
This type describes a File entity.
49+
"""
50+
type File {
51+
filename: String!
52+
encoding: String!
53+
mimetype: String!
54+
}
55+
`,
56+
},
57+
},
58+
actions: {
59+
hello: {
60+
graphql: {
61+
query: "hello: String!",
62+
},
63+
handler() {
64+
return "Hello Moleculer!";
65+
},
66+
},
67+
singleUpload: {
68+
graphql: {
69+
mutation: "singleUpload(file: Upload!): File!",
70+
fileUploadArg: "file",
71+
},
72+
async handler(ctx) {
73+
const fileChunks = [];
74+
for await (const chunk of ctx.params) {
75+
fileChunks.push(chunk);
76+
}
77+
const fileContents = Buffer.concat(fileChunks);
78+
ctx.broker.logger.info("Uploaded File Contents:");
79+
ctx.broker.logger.info(fileContents.toString());
80+
return ctx.meta.$fileInfo;
81+
},
82+
},
83+
},
84+
});
85+
86+
broker.start().then(async () => {
87+
broker.repl();
88+
89+
broker.logger.info("----------------------------------------------------------");
90+
broker.logger.info("For information about creating a file upload request,");
91+
broker.logger.info(
92+
"see https://github.com/jaydenseric/graphql-multipart-request-spec#curl-request"
93+
);
94+
broker.logger.info("----------------------------------------------------------");
95+
});

index.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ declare module "moleculer-apollo-server" {
55
import { SchemaDirectiveVisitor, IResolvers } from "graphql-tools";
66

77
export {
8-
GraphQLUpload,
98
GraphQLExtension,
109
gql,
1110
ApolloError,
@@ -18,6 +17,8 @@ declare module "moleculer-apollo-server" {
1817
defaultPlaygroundOptions,
1918
} from "apollo-server-core";
2019

20+
export { GraphQLUpload } from 'graphql-upload';
21+
2122
export * from "graphql-tools";
2223

2324
export interface ApolloServerOptions {

index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515
"use strict";
1616

1717
const core = require("apollo-server-core");
18+
const { GraphQLUpload } = require("graphql-upload");
1819
const { ApolloServer } = require("./src/ApolloServer");
1920
const ApolloService = require("./src/service");
2021
const gql = require("./src/gql");
2122

2223
module.exports = {
2324
// Core
24-
GraphQLUpload: core.GraphQLUpload,
2525
GraphQLExtension: core.GraphQLExtension,
2626
gql: core.gql,
2727
ApolloError: core.ApolloError,
@@ -36,6 +36,9 @@ module.exports = {
3636
// GraphQL tools
3737
...require("graphql-tools"),
3838

39+
// GraphQL Upload
40+
GraphQLUpload,
41+
3942
// Apollo Server
4043
ApolloServer,
4144

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"dependencies": {
7171
"@apollographql/graphql-playground-html": "^1.6.24",
7272
"@hapi/accept": "^3.2.4",
73+
"@types/graphql-upload": "^8.0.0",
7374
"apollo-server-core": "^2.10.0",
7475
"dataloader": "^2.0.0",
7576
"graphql-subscriptions": "^1.1.0",

src/service.js

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ module.exports = function(mixinOptions) {
133133
nullIfError = false,
134134
params: staticParams = {},
135135
rootParams = {},
136+
fileUploadArg = null,
136137
} = def;
137138
const rootKeys = Object.keys(rootParams);
138139

@@ -189,6 +190,27 @@ module.exports = function(mixinOptions) {
189190
return Array.isArray(dataLoaderKey)
190191
? await dataLoader.loadMany(dataLoaderKey)
191192
: await dataLoader.load(dataLoaderKey);
193+
} else if (fileUploadArg != null && args[fileUploadArg] != null) {
194+
if (Array.isArray(args[fileUploadArg])) {
195+
return await Promise.all(
196+
args[fileUploadArg].map(async uploadPromise => {
197+
const {
198+
createReadStream,
199+
...$fileInfo
200+
} = await uploadPromise;
201+
const stream = createReadStream();
202+
return context.ctx.call(actionName, stream, {
203+
meta: { $fileInfo },
204+
});
205+
})
206+
);
207+
}
208+
209+
const { createReadStream, ...$fileInfo } = await args[fileUploadArg];
210+
const stream = createReadStream();
211+
return await context.ctx.call(actionName, stream, {
212+
meta: { $fileInfo },
213+
});
192214
} else {
193215
const params = {};
194216
if (root && rootKeys) {
@@ -411,7 +433,10 @@ module.exports = function(mixinOptions) {
411433
const name = this.getFieldName(mutation);
412434
mutations.push(mutation);
413435
resolver.Mutation[name] = this.createActionResolver(
414-
action.name
436+
action.name,
437+
{
438+
fileUploadArg: def.fileUploadArg,
439+
}
415440
);
416441
});
417442
}

0 commit comments

Comments
 (0)