Skip to content

Commit

Permalink
Transform Uploads to Streams before calling action (#71)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
dylanwulf authored Apr 4, 2020
1 parent fcef73c commit e89ec85
Show file tree
Hide file tree
Showing 7 changed files with 332 additions and 4 deletions.
104 changes: 104 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.meta.$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,
Expand Down Expand Up @@ -312,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)
Expand Down
95 changes: 95 additions & 0 deletions examples/upload/index.js
Original file line number Diff line number Diff line change
@@ -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("----------------------------------------------------------");
});
3 changes: 2 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ declare module "moleculer-apollo-server" {
import { SchemaDirectiveVisitor, IResolvers } from "graphql-tools";

export {
GraphQLUpload,
GraphQLExtension,
gql,
ApolloError,
Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@
"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,
GraphQLExtension: core.GraphQLExtension,
gql: core.gql,
ApolloError: core.ApolloError,
Expand All @@ -36,6 +36,9 @@ module.exports = {
// GraphQL tools
...require("graphql-tools"),

// GraphQL Upload
GraphQLUpload,

// Apollo Server
ApolloServer,

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 26 additions & 1 deletion src/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ module.exports = function(mixinOptions) {
nullIfError = false,
params: staticParams = {},
rootParams = {},
fileUploadArg = null,
} = def;
const rootKeys = Object.keys(rootParams);

Expand Down Expand Up @@ -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 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) {
Expand Down Expand Up @@ -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: def.fileUploadArg,
}
);
});
}
Expand Down
Loading

0 comments on commit e89ec85

Please sign in to comment.