From f421deaabd019cfb1be95b4f79969ba6f284ce14 Mon Sep 17 00:00:00 2001 From: Jayden Seric Date: Thu, 10 Oct 2019 10:54:16 +1100 Subject: [PATCH] Refactor the GraphQL API. --- api/package.json | 4 +-- api/resolvers.mjs | 69 --------------------------------------- api/schema.js | 8 +++++ api/server.js | 75 +++++++++++++++++++++++++++++++++++++++++++ api/server.mjs | 28 ---------------- api/types.mjs | 17 ---------- api/types/File.js | 29 +++++++++++++++++ api/types/Mutation.js | 43 +++++++++++++++++++++++++ api/types/Query.js | 13 ++++++++ 9 files changed, 170 insertions(+), 116 deletions(-) delete mode 100644 api/resolvers.mjs create mode 100644 api/schema.js create mode 100644 api/server.js delete mode 100644 api/server.mjs delete mode 100644 api/types.mjs create mode 100644 api/types/File.js create mode 100644 api/types/Mutation.js create mode 100644 api/types/Query.js diff --git a/api/package.json b/api/package.json index 4f93a7c..60fed6c 100644 --- a/api/package.json +++ b/api/package.json @@ -34,7 +34,7 @@ }, "scripts": { "dev": "nodemon -i db.json", - "start": "node --experimental-modules -r dotenv/config server", - "test": "eslint . --ext mjs,js && prettier '**/*.{json,yml,md}' -l" + "start": "node -r dotenv/config server", + "test": "eslint . && prettier '**/*.{json,yml,md}' -l" } } diff --git a/api/resolvers.mjs b/api/resolvers.mjs deleted file mode 100644 index 5d108f7..0000000 --- a/api/resolvers.mjs +++ /dev/null @@ -1,69 +0,0 @@ -import fs from 'fs' -import apolloServerKoa from 'apollo-server-koa' -import lowdb from 'lowdb' -import FileSync from 'lowdb/adapters/FileSync' -import mkdirp from 'mkdirp' -import promisesAll from 'promises-all' -import shortid from 'shortid' - -const UPLOAD_DIR = './uploads' -const db = lowdb(new FileSync('db.json')) - -// Seed an empty DB. -db.defaults({ uploads: [] }).write() - -// Ensure upload directory exists. -mkdirp.sync(UPLOAD_DIR) - -const storeFS = ({ stream, filename }) => { - const id = shortid.generate() - const path = `${UPLOAD_DIR}/${id}-${filename}` - return new Promise((resolve, reject) => - stream - .on('error', error => { - if (stream.truncated) - // Delete the truncated file. - fs.unlinkSync(path) - reject(error) - }) - .pipe(fs.createWriteStream(path)) - .on('error', error => reject(error)) - .on('finish', () => resolve({ id, path })) - ) -} - -const storeDB = file => - db - .get('uploads') - .push(file) - .last() - .write() - -const processUpload = async upload => { - const { createReadStream, filename, mimetype } = await upload - const stream = createReadStream() - const { id, path } = await storeFS({ stream, filename }) - return storeDB({ id, filename, mimetype, path }) -} - -export default { - Upload: apolloServerKoa.GraphQLUpload, - Query: { - uploads: () => db.get('uploads').value() - }, - Mutation: { - singleUpload: (obj, { file }) => processUpload(file), - async multipleUpload(obj, { files }) { - const { resolve, reject } = await promisesAll.all( - files.map(processUpload) - ) - - if (reject.length) - reject.forEach(({ name, message }) => - console.error(`${name}: ${message}`) - ) - - return resolve - } - } -} diff --git a/api/schema.js b/api/schema.js new file mode 100644 index 0000000..c161378 --- /dev/null +++ b/api/schema.js @@ -0,0 +1,8 @@ +const { GraphQLSchema } = require('graphql') +const { MutationType } = require('./types/Mutation') +const { QueryType } = require('./types/Query') + +exports.schema = new GraphQLSchema({ + query: QueryType, + mutation: MutationType +}) diff --git a/api/server.js b/api/server.js new file mode 100644 index 0000000..a745012 --- /dev/null +++ b/api/server.js @@ -0,0 +1,75 @@ +const { createWriteStream, unlink } = require('fs') +const { ApolloServer } = require('apollo-server-koa') +const Koa = require('koa') +const lowdb = require('lowdb') +const FileSync = require('lowdb/adapters/FileSync') +const mkdirp = require('mkdirp') +const shortid = require('shortid') +const { schema } = require('./schema') + +const UPLOAD_DIR = './uploads' +const db = lowdb(new FileSync('db.json')) + +// Seed an empty DB. +db.defaults({ uploads: [] }).write() + +// Ensure upload directory exists. +mkdirp.sync(UPLOAD_DIR) + +/** + * Stores a GraphQL file upload. The file is stored in the filesystem and its + * metadata is recorded in the DB. + * @param {GraphQLUpload} upload GraphQL file upload. + * @returns {object} File metadata. + */ +const storeUpload = async upload => { + const { createReadStream, filename, mimetype } = await upload + const stream = createReadStream() + const id = shortid.generate() + const path = `${UPLOAD_DIR}/${id}-${filename}` + const file = { id, filename, mimetype, path } + + // Store the file in the filesystem. + await new Promise((resolve, reject) => { + stream + .on('error', error => { + unlink(path, () => { + reject(error) + }) + }) + .pipe(createWriteStream(path)) + .on('error', reject) + .on('finish', resolve) + }) + + // Record the file metadata in the DB. + db.get('uploads') + .push(file) + .write() + + return file +} + +const app = new Koa() +const server = new ApolloServer({ + uploads: { + // Limits here should be stricter than config for surrounding + // infrastructure such as Nginx so errors can be handled elegantly by + // graphql-upload: + // https://github.com/jaydenseric/graphql-upload#type-processrequestoptions + maxFileSize: 10000000, // 10 MB + maxFiles: 20 + }, + schema, + context: { db, storeUpload } +}) + +server.applyMiddleware({ app }) + +app.listen(process.env.PORT, error => { + if (error) throw error + + console.info( + `Serving http://localhost:${process.env.PORT} for ${process.env.NODE_ENV}.` + ) +}) diff --git a/api/server.mjs b/api/server.mjs deleted file mode 100644 index 7032121..0000000 --- a/api/server.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import apolloServerKoa from 'apollo-server-koa' -import Koa from 'koa' -import resolvers from './resolvers' -import typeDefs from './types' - -const app = new Koa() -const server = new apolloServerKoa.ApolloServer({ - typeDefs, - resolvers, - uploads: { - // Limits here should be stricter than config for surrounding - // infrastructure such as Nginx so errors can be handled elegantly by - // graphql-upload: - // https://github.com/jaydenseric/graphql-upload#type-uploadoptions - maxFileSize: 10000000, // 10 MB - maxFiles: 20 - } -}) - -server.applyMiddleware({ app }) - -app.listen(process.env.PORT, error => { - if (error) throw error - - console.info( - `Serving http://localhost:${process.env.PORT} for ${process.env.NODE_ENV}.` - ) -}) diff --git a/api/types.mjs b/api/types.mjs deleted file mode 100644 index 066ccca..0000000 --- a/api/types.mjs +++ /dev/null @@ -1,17 +0,0 @@ -export default /* GraphQL */ ` - type File { - id: ID! - path: String! - filename: String! - mimetype: String! - } - - type Query { - uploads: [File] - } - - type Mutation { - singleUpload(file: Upload!): File! - multipleUpload(files: [Upload!]!): [File!]! - } -` diff --git a/api/types/File.js b/api/types/File.js new file mode 100644 index 0000000..b8e5cef --- /dev/null +++ b/api/types/File.js @@ -0,0 +1,29 @@ +const { + GraphQLNonNull, + GraphQLObjectType, + GraphQLString, + GraphQLID +} = require('graphql') + +exports.FileType = new GraphQLObjectType({ + name: 'File', + description: 'A stored file.', + fields: () => ({ + id: { + description: 'Unique ID.', + type: GraphQLNonNull(GraphQLID) + }, + path: { + description: 'Where it’s stored in the filesystem.', + type: GraphQLNonNull(GraphQLString) + }, + filename: { + description: 'Filename, including extension.', + type: GraphQLNonNull(GraphQLString) + }, + mimetype: { + description: 'MIME type.', + type: GraphQLNonNull(GraphQLString) + } + }) +}) diff --git a/api/types/Mutation.js b/api/types/Mutation.js new file mode 100644 index 0000000..52921b8 --- /dev/null +++ b/api/types/Mutation.js @@ -0,0 +1,43 @@ +const { GraphQLUpload } = require('apollo-server-koa') +const { GraphQLList, GraphQLObjectType, GraphQLNonNull } = require('graphql') +const promisesAll = require('promises-all') +const { FileType } = require('./File') + +exports.MutationType = new GraphQLObjectType({ + name: 'Mutation', + fields: () => ({ + singleUpload: { + description: 'Stores a single file.', + type: GraphQLNonNull(FileType), + args: { + file: { + description: 'File to store.', + type: GraphQLNonNull(GraphQLUpload) + } + }, + resolve: (parent, { file }, { storeUpload }) => storeUpload(file) + }, + multipleUpload: { + description: 'Stores multiple files.', + type: GraphQLNonNull(GraphQLList(GraphQLNonNull(FileType))), + args: { + files: { + description: 'Files to store.', + type: GraphQLNonNull(GraphQLList(GraphQLNonNull(GraphQLUpload))) + } + }, + async resolve(parent, { files }, { storeUpload }) { + const { resolve, reject } = await promisesAll.all( + files.map(storeUpload) + ) + + if (reject.length) + reject.forEach(({ name, message }) => + console.error(`${name}: ${message}`) + ) + + return resolve + } + } + }) +}) diff --git a/api/types/Query.js b/api/types/Query.js new file mode 100644 index 0000000..c7a895f --- /dev/null +++ b/api/types/Query.js @@ -0,0 +1,13 @@ +const { GraphQLList, GraphQLObjectType, GraphQLNonNull } = require('graphql') +const { FileType } = require('./File') + +exports.QueryType = new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + uploads: { + description: 'All stored files.', + type: GraphQLNonNull(GraphQLList(GraphQLNonNull(FileType))), + resolve: (source, args, { db }) => db.get('uploads').value() + } + }) +})