From 17849898aeec8f0cc4cc2ac7f3ac7bbcf770106d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 18 Jan 2017 00:09:26 -0700 Subject: [PATCH] First pass --- app.js | 2 + package.json | 4 ++ routes/api/graph/index.js | 26 ++++++++++ routes/api/graph/loaders.js | 97 +++++++++++++++++++++++++++++++++++ routes/api/graph/mutators.js | 65 +++++++++++++++++++++++ routes/api/graph/resolvers.js | 52 +++++++++++++++++++ routes/api/graph/typeDefs.js | 66 ++++++++++++++++++++++++ 7 files changed, 312 insertions(+) create mode 100644 routes/api/graph/index.js create mode 100644 routes/api/graph/loaders.js create mode 100644 routes/api/graph/mutators.js create mode 100644 routes/api/graph/resolvers.js create mode 100644 routes/api/graph/typeDefs.js diff --git a/app.js b/app.js index 9cdad4ea3..acc262aad 100644 --- a/app.js +++ b/app.js @@ -77,6 +77,8 @@ app.use(session(session_opts)); app.use(passport.initialize()); app.use(passport.session()); +app.use('/api/v1/graph', require('./routes/api/graph')); + //============================================================================== // CSRF MIDDLEWARE //============================================================================== diff --git a/package.json b/package.json index e6e211d07..85712bf5a 100644 --- a/package.json +++ b/package.json @@ -54,12 +54,16 @@ "commander": "^2.9.0", "connect-redis": "^3.1.0", "csurf": "^1.9.0", + "dataloader": "^1.2.0", "debug": "^2.2.0", "dotenv": "^4.0.0", "ejs": "^2.5.2", "env-rewrite": "^1.0.2", "express": "^4.14.0", "express-session": "^1.14.2", + "graphql": "^0.8.2", + "graphql-server-express": "^0.5.0", + "graphql-tools": "^0.9.0", "helmet": "^3.1.0", "jsonwebtoken": "^7.1.9", "kue": "^0.11.5", diff --git a/routes/api/graph/index.js b/routes/api/graph/index.js new file mode 100644 index 000000000..e97cfcadb --- /dev/null +++ b/routes/api/graph/index.js @@ -0,0 +1,26 @@ +const express = require('express'); +const apollo = require('graphql-server-express'); +const tools = require('graphql-tools'); +const resolvers = require('./resolvers'); +const typeDefs = require('./typeDefs'); +const loaders = require('./loaders'); +const mutators = require('./mutators'); + +const schema = tools.makeExecutableSchema({typeDefs, resolvers}); +const router = express.Router(); + +router.use('/ql', apollo.graphqlExpress((req) => { + + let context = {req}; + + context.loaders = loaders(context); + context.mutators = mutators(context); + + return { + schema, + context + }; +})); +router.use('/iql', apollo.graphiqlExpress({endpointURL: '/api/v1/graph/ql'})); + +module.exports = router; diff --git a/routes/api/graph/loaders.js b/routes/api/graph/loaders.js new file mode 100644 index 000000000..2d7ed4f69 --- /dev/null +++ b/routes/api/graph/loaders.js @@ -0,0 +1,97 @@ +const DataLoader = require('dataloader'); +const _ = require('lodash'); + +const Comment = require('../../../models/comment'); +const User = require('../../../models/user'); +const Action = require('../../../models/action'); +const Asset = require('../../../models/asset'); +const Settings = require('../../../models/setting'); + +class SingletonResolver { + constructor(resolver) { + this._cache = null; + this._resolver = resolver; + } + + load() { + if (this._cache) { + return this._cache; + } + + let promise = this._resolver(arguments).then((result) => { + return result; + }); + + // Set the promise on the cache. + this._cache = promise; + + return promise; + } +} + +const arrayJoinBy = (ids, key) => (items) => { + const itemsByKey = _.groupBy(items, key); + return ids.map((id) => { + if (id in itemsByKey) { + return itemsByKey[id]; + } + + return []; + }); +}; + +const singleJoinBy = (ids, key) => (items) => { + const itemsByKey = _.groupBy(items, key); + return ids.map((id) => { + if (id in itemsByKey) { + return itemsByKey[id][0]; + } + + return null; + }); +}; + +const genAssetByID = (ids) => Asset.find({ + id: { + $in: ids + } +}).then(singleJoinBy(ids, 'id')); + +const genActionsByID = (ids, user = {}) => Action.getActionSummaries(ids, user.id).then(arrayJoinBy(ids, 'item_id')); + +const genCommentsByAssetID = (ids) => Comment.find({ + asset_id: { + $in: ids + }, + parent_id: null, + status: { + $in: [null, 'accepted'] + } +}).then(arrayJoinBy(ids, 'asset_id')); + +const genCommentsByParentID = (ids) => Comment.find({ + parent_id: { + $in: ids + }, + status: { + $in: [null, 'accepted'] + } +}).then(arrayJoinBy(ids, 'parent_id')); + +module.exports = (context) => ({ + Comments: { + getByParentID: new DataLoader((ids) => genCommentsByParentID(ids)), + getByAssetID: new DataLoader((ids) => genCommentsByAssetID(ids)), + }, + Actions: { + getByID: new DataLoader((ids) => genActionsByID(ids, context.req.user)), + }, + Users: { + getByID: new DataLoader((ids) => User.findByIdArray(ids)) + }, + Assets: { + getByID: new DataLoader((ids) => genAssetByID(ids)), + getAll: new SingletonResolver(() => Asset.find({})) + }, + Settings: new SingletonResolver(() => Settings.retrieve()) +}); diff --git a/routes/api/graph/mutators.js b/routes/api/graph/mutators.js new file mode 100644 index 000000000..157d715db --- /dev/null +++ b/routes/api/graph/mutators.js @@ -0,0 +1,65 @@ +const errors = require('../../../errors'); + +const Asset = require('../../../models/asset'); +const Comment = require('../../../models/comment'); + +const createComment = (context, {body, asset_id, parent_id}, wordlist = {}) => { + + // Decide the status based on whether or not the current asset/settings + // has pre-mod enabled or not. If the comment was rejected based on the + // wordlist, then reject it, otherwise if the moderation setting is + // premod, set it to `premod`. + let status; + + if (wordlist.banned) { + status = Promise.resolve('rejected'); + } else { + status = Asset + .rectifySettings(Asset.findById(asset_id).then((asset) => { + if (!asset) { + return Promise.reject(errors.ErrNotFound); + } + + // Check to see if the asset has closed commenting... + if (asset.isClosed) { + + // They have, ensure that we send back an error. + return Promise.reject(new errors.ErrAssetCommentingClosed(asset.closedMessage)); + } + + return asset; + })) + + // Return `premod` if pre-moderation is enabled and an empty "new" status + // in the event that it is not in pre-moderation mode. + .then(({moderation, charCountEnable, charCount}) => { + + // Reject if the comment is too long + if (charCountEnable && body.length > charCount) { + return 'rejected'; + } + return moderation === 'pre' ? 'premod' : null; + }); + } + + return status.then((status) => Comment.publicCreate({ + body, + asset_id, + parent_id, + status, + author_id: context.req.user.id + })) + .then((comment) => { + if (wordlist.suspect) { + return Comment + .addAction(comment.id, null, 'flag', {field: 'body', details: 'Matched suspect word filters.'}) + .then(() => comment); + } + + return comment; + }); +}; + +module.exports = (context) => ({ + createComment: (comment) => createComment(context, comment) +}); diff --git a/routes/api/graph/resolvers.js b/routes/api/graph/resolvers.js new file mode 100644 index 000000000..4c53eb366 --- /dev/null +++ b/routes/api/graph/resolvers.js @@ -0,0 +1,52 @@ +module.exports = { + Query: { + assets(_, args, {loaders}) { + return loaders.Assets.getAll.load(); + }, + asset(_, {id}, {loaders}) { + return loaders.Assets.getByID.load(id); + }, + settings(_, args, {loaders}) { + return loaders.Settings.load(); + } + }, + Mutation: { + createComment(_, {asset_id, parent_id, body}, {mutators}) { + return mutators.createComment({asset_id, parent_id, body}); + } + }, + Asset: { + comments({id}, _, {loaders}) { + return loaders.Comments.getByAssetID.load(id); + }, + settings({settings = null}, _, {loaders}) { + return loaders.Settings.load() + .then((globalSettings) => { + + if (settings) { + settings = Object.assign({}, settings, globalSettings); + } else { + settings = globalSettings; + } + + return settings; + }); + } + }, + User: { + actions({id}, _, {loaders}) { + return loaders.Actions.getByID.load(id); + } + }, + Comment: { + user({author_id}, _, {loaders}) { + return loaders.Users.getByID.load(author_id); + }, + replies({id}, _, {loaders}) { + return loaders.Comments.getByParentID.load(id); + }, + actions({id}, _, {loaders}) { + return loaders.Actions.getByID.load(id); + } + } +}; diff --git a/routes/api/graph/typeDefs.js b/routes/api/graph/typeDefs.js new file mode 100644 index 000000000..ba4a5c7f1 --- /dev/null +++ b/routes/api/graph/typeDefs.js @@ -0,0 +1,66 @@ +const typeDefs = [` +type UserSettings { + bio: String +} + +type User { + id: ID! + displayName: String! + actions: [Action] + settings: UserSettings +} + +type Comment { + id: ID! + body: String! + user: User + replies(limit: Int = 3): [Comment] + actions: [Action] +} + +type Action { + id: ID! + item_id: ID! + action_type: String! + count: Int + current_user: Action + updated_at: String + created_at: String +} + +type Settings { + moderation: String + infoBoxEnable: Boolean + infoBoxContent: String + closeTimeout: Int + closedMessage: String + charCountEnable: Boolean + charCount: Int + requireEmailConfirmation: Boolean +} + +type Asset { + id: ID! + title: String + url: String + comments: [Comment] + settings: Settings! +} + +type Query { + settings: Settings + assets: [Asset] + asset(id: ID!): Asset! +} + +type Mutation { + createComment(asset_id: ID!, parent_id: ID, body: String!): Comment +} + +schema { + query: Query + mutation: Mutation +} +`]; + +module.exports = typeDefs;