Adjusted placement of server side graph code.

This commit is contained in:
Wyatt Johnson
2017-01-19 18:26:33 -07:00
parent fec18b50d1
commit f286263f1f
15 changed files with 60 additions and 48 deletions
+24
View File
@@ -0,0 +1,24 @@
const loaders = require('./loaders');
const mutators = require('./mutators');
const schema = require('./schema');
module.exports = {
createGraphOptions: (req) => {
let context = {};
// Load the current logged in user to `user`, otherwise this'll be null.
context.user = req.user;
// Create the loaders.
context.loaders = loaders(context);
// Create the mutators.
context.mutators = mutators(context);
return {
schema,
context
};
}
};
+171
View File
@@ -0,0 +1,171 @@
const DataLoader = require('dataloader');
const _ = require('lodash');
const url = require('url');
const errors = require('../errors');
const scraper = require('../services/scraper');
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');
/**
* SingletonResolver is a cached loader for a single result.
*/
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;
}
}
/**
* This joins a set of results with a specific keys and sets an empty array in
* place if it was not found.
* @param {Array} ids ids to locate
* @param {String} key key to group by
* @return {Array} array of results
*/
const arrayJoinBy = (ids, key) => (items) => {
const itemsByKey = _.groupBy(items, key);
return ids.map((id) => {
if (id in itemsByKey) {
return itemsByKey[id];
}
return [];
});
};
/**
* This joins a set of results with a specific keys and sets null in place if it
* was not found.
* @param {Array} ids ids to locate
* @param {String} key key to group by
* @return {Array} array of results
*/
const singleJoinBy = (ids, key) => (items) => {
const itemsByKey = _.groupBy(items, key);
return ids.map((id) => {
if (id in itemsByKey) {
return itemsByKey[id][0];
}
return null;
});
};
/**
* Retrieves assets by an array of ids.
* @param {Array} ids array of ids to lookup
*/
const genAssetsByID = (ids) => Asset.find({
id: {
$in: ids
}
}).then(singleJoinBy(ids, 'id'));
/**
* Retrieves actions by an array of ids.
* @param {Array} ids array of ids to lookup
*/
const genActionsByID = (ids, user = {}) => Action.getActionSummaries(ids, user.id).then(arrayJoinBy(ids, 'item_id'));
/**
* Retrieves comments by an array of asset id's.
* @param {Array} ids array of ids to lookup
*/
const genCommentsByAssetID = (ids) => Comment.find({
asset_id: {
$in: ids
},
parent_id: null,
status: {
$in: [null, 'accepted']
}
}).then(arrayJoinBy(ids, 'asset_id'));
/**
* Retrieves comments by an array of parent ids.
* @param {Array} ids array of ids to lookup
*/
const genCommentsByParentID = (ids) => Comment.find({
parent_id: {
$in: ids
},
status: {
$in: [null, 'accepted']
}
}).then(arrayJoinBy(ids, 'parent_id'));
/**
* This endpoint find or creates an asset at the given url when it is loaded.
* @param {String} asset_url the url passed in from the query
* @returns {Promise} resolves to the asset
*/
const findOrCreateAssetByURL = (asset_url) => {
// Verify that the asset_url is parsable.
let parsed_asset_url = url.parse(asset_url);
if (!parsed_asset_url.protocol) {
return Promise.reject(errors.ErrInvalidAssetURL);
}
return Asset.findOrCreateByUrl(asset_url)
.then((asset) => {
// If the asset wasn't scraped before, scrape it! Otherwise just return
// the asset.
if (!asset.scraped) {
return scraper.create(asset).then(() => asset);
}
return asset;
});
};
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
* @return {Object} object of loaders
*/
const createLoaders = (context) => ({
Comments: {
getByParentID: new DataLoader((ids) => genCommentsByParentID(ids)),
getByAssetID: new DataLoader((ids) => genCommentsByAssetID(ids)),
},
Actions: {
getByID: new DataLoader((ids) => genActionsByID(ids, context.user)),
},
Users: {
getByID: new DataLoader((ids) => User.findByIdArray(ids))
},
Assets: {
// TODO: decide whether we want to move these to mutators or not, as in fact
// this operation create a new asset if one isn't found.
getByURL: (url) => findOrCreateAssetByURL(url),
getByID: new DataLoader((ids) => genAssetsByID(ids)),
getAll: new SingletonResolver(() => Asset.find({}))
},
Settings: new SingletonResolver(() => Settings.retrieve())
});
module.exports = createLoaders;
+215
View File
@@ -0,0 +1,215 @@
const errors = require('../errors');
const Action = require('../models/action');
const Asset = require('../models/asset');
const Comment = require('../models/comment');
const User = require('../models/user');
const Wordlist = require('../services/wordlist');
/**
* Creates a new comment.
* @param {Object} user the user performing the request
* @param {String} body body of the comment
* @param {String} asset_id asset for the comment
* @param {String} parent_id optional parent of the comment
* @param {String} [status=null] the status of the new comment
* @return {Promise} resolves to the created comment
*/
const createComment = ({user}, {body, asset_id, parent_id = null}, status = null) => {
return Comment.publicCreate({
body,
asset_id,
parent_id,
status,
author_id: user.id
});
};
/**
* Filters the comment object and outputs wordlist results.
* @param {Object} context graphql context
* @param {String} body body of a comment
* @return {Object} resolves to the wordlist results
*/
const filterNewComment = (context, {body}) => {
// Create a new instance of the Wordlist.
const wl = new Wordlist();
// Load the wordlist and filter the comment content.
return wl.load().then(() => wl.scan('body', body));
};
/**
* This resolves a given comment's status to take into account moderator actions
* are applied.
* @param {Object} context graphql context
* @param {String} body body of the comment
* @param {String} asset_id asset for the comment
* @param {Object} [wordlist={}] the results of the wordlist scan
* @return {Promise} resolves to the comment's status
*/
const resolveNewCommentStatus = (context, {asset_id, body}, 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;
};
/**
* createPublicComment is designed to create a comment from a public source. It
* validates the comment, and performs some automated moderator actions based on
* the settings.
* @param {Object} context the graphql context
* @param {Object} commentInput the new comment to be created
* @return {Promise} resolves to a new comment
*/
const createPublicComment = (context, commentInput) => {
// First we filter the comment contents to ensure that we note any validation
// issues.
return filterNewComment(context, commentInput)
// We then take the wordlist and the comment into consideration when
// considering what status to assign the new comment, and resolve the new
// status to set the comment to.
.then((wordlist) => resolveNewCommentStatus(context, commentInput, wordlist)
// Then we actually create the comment with the new status.
.then((status) => createComment(context, commentInput, status))
.then((comment) => {
// If the comment was flagged as being suspect, we need to add a
// flag to it to indicate that it needs to be looked at.
// Otherwise just return the new comment.
if (wordlist.suspect) {
// TODO: this is kind of fragile, we should refactor this to resolve
// all these const's that we're using like 'comments', 'flag' to be
// defined in a checkable schema.
return createAction(null, {
item_id: comment.id,
item_type: 'comments',
action_type: 'flag',
metadata: {
field: 'body',
details: 'Matched suspect word filters.'
}
}).then(() => comment);
}
// Finally, we return the comment.
return comment;
}));
};
/**
* Creates an action on a item.
* @param {Object} user the user performing the request
* @param {String} item_id id of the item to add the action to
* @param {String} item_type type of the item
* @param {String} action_type type of the action
* @return {Promise} resolves to the action created
*/
const createAction = ({user = {}}, {item_id, item_type, action_type, metadata = {}}) => {
return Action.insertUserAction({
item_id,
item_type,
user_id: user.id,
action_type,
metadata
});
};
/**
* Deletes an action based on the user id if the user owns that action.
* @param {Object} user the user performing the request
* @param {[type]} id [description]
* @return {[type]} [description]
*/
const deleteAction = ({user}, {id}) => {
return Action.remove({
id,
user_id: user.id
});
};
/**
* Updates a users settings.
* @param {Object} user the user performing the request
* @param {String} bio the new user bio
* @return {Promise}
*/
const updateUserSettings = ({user}, {bio}) => {
return User.updateSettings(user.id, {bio});
};
module.exports = (context) => {
// TODO: refactor to something that'll return an error in the event an attempt
// is made to mutate state while not logged in. There's got to be a better way
// to do this.
if (context.user) {
return {
Comment: {
create: (comment) => createPublicComment(context, comment)
},
Action: {
create: (action) => createAction(context, action),
delete: (action) => deleteAction(context, action)
},
User: {
updateSettings: (settings) => updateUserSettings(context, settings)
}
};
}
return {
Comment: {
create: () => {}
},
Action: {
create: () => {},
delete: () => {}
},
User: {
updateSettings: () => {}
}
};
};
+19
View File
@@ -0,0 +1,19 @@
const Action = {
action_type({action_type}) {
// TODO: remove once we cast the data model to have uppercase action
// types.
return action_type.toUpperCase();
},
item_type({item_type}) {
// TODO: remove once we cast the data model to have uppercase item
// types.
return item_type.toUpperCase();
},
user({user_id}, _, {loaders}) {
return loaders.Users.getByID.load(user_id);
}
};
module.exports = Action;
+16
View File
@@ -0,0 +1,16 @@
const ActionSummary = {
action_type({action_type}) {
// TODO: remove once we cast the data model to have uppercase action
// types.
return action_type.toUpperCase();
},
item_type({item_type}) {
// TODO: remove once we cast the data model to have uppercase item
// types.
return item_type.toUpperCase();
}
};
module.exports = ActionSummary;
+20
View File
@@ -0,0 +1,20 @@
const 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;
});
}
};
module.exports = Asset;
+13
View File
@@ -0,0 +1,13 @@
const 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);
}
};
module.exports = Comment;
+17
View File
@@ -0,0 +1,17 @@
const Action = require('./action');
const ActionSummary = require('./action_summary');
const Asset = require('./asset');
const Comment = require('./comment');
const RootMutation = require('./root_mutation');
const RootQuery = require('./root_query');
const User = require('./user');
module.exports = {
Action,
ActionSummary,
Asset,
Comment,
RootMutation,
RootQuery,
User
};
+16
View File
@@ -0,0 +1,16 @@
const RootMutation = {
createComment(_, {asset_id, parent_id, body}, {mutators}) {
return mutators.Comment.create({asset_id, parent_id, body});
},
createAction(_, {action}, {mutators}) {
return mutators.Action.create(action);
},
deleteAction(_, {id}, {mutators}) {
return mutators.Action.delete({id});
},
updateUserSettings(_, {settings}, {mutators}) {
return mutators.User.updateSettings(settings);
}
};
module.exports = RootMutation;
+25
View File
@@ -0,0 +1,25 @@
const RootQuery = {
assets(_, args, {loaders}) {
return loaders.Assets.getAll.load();
},
asset(_, {id = null, url}, {loaders}) {
if (id) {
// TODO: we may not always have a comment stream here, therefore, when we
// load it, we may also need to create with the url. This may also have to
// move the logic over to the mutators function as an upsert operation
// possibly.
return loaders.Assets.getByID.load(id);
} else {
return loaders.Assets.getByURL(url);
}
},
settings(_, args, {loaders}) {
return loaders.Settings.load();
},
me(_, args, {user}) {
return user;
}
};
module.exports = RootQuery;
+7
View File
@@ -0,0 +1,7 @@
const User = {
actions({id}, _, {loaders}) {
return loaders.Actions.getByID.load(id);
}
};
module.exports = User;
+8
View File
@@ -0,0 +1,8 @@
const tools = require('graphql-tools');
const resolvers = require('./resolvers');
const typeDefs = require('./typeDefs');
const schema = tools.makeExecutableSchema({typeDefs, resolvers});
module.exports = schema;
+123
View File
@@ -0,0 +1,123 @@
// TODO: Adjust `RootQuery.asset(id: ID, url: URL)` to instead be
// `RootQuery.asset(id: ID, url: URL!)` because we'll always need the url, if
// this change is done now everything will likely break on the front end.
const typeDefs = [`
type UserSettings {
bio: String
}
type User {
id: ID!
displayName: String!
actions: [ActionSummary]
settings: UserSettings
}
type Comment {
id: ID!
body: String!
user: User
replies(limit: Int = 3): [Comment]
actions: [ActionSummary]
}
enum ITEM_TYPE {
ASSETS
COMMENTS
USERS
}
enum ACTION_TYPE {
LIKE
FLAG
}
interface ActionInterface {
action_type: ACTION_TYPE!
item_type: ITEM_TYPE!
}
type Action implements ActionInterface {
id: ID!
item_id: ID!
action_type: ACTION_TYPE!
item_type: ITEM_TYPE!
user: User!
updated_at: String
created_at: String
}
type ActionSummary implements ActionInterface {
action_type: ACTION_TYPE!
item_type: ITEM_TYPE!
count: Int
current_user: Action
}
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!
currentUser: User
}
scalar URL
type RootQuery {
settings: Settings
assets: [Asset]
asset(id: ID, url: URL): Asset
me: User
}
input CreateActionInput {
# the type of action.
action_type: ACTION_TYPE!
# the type of the item.
item_type: ITEM_TYPE!
# the id of the item that is related to the action.
item_id: ID!
}
input UpdateUserSettingsInput {
# user bio
bio: String!
}
type RootMutation {
# creates a comment on the asset.
createComment(asset_id: ID!, parent_id: ID, body: String!): Comment
# creates an action based on an input.
createAction(action: CreateActionInput!): Action
# delete an action based on the action id.
deleteAction(id: ID!): Boolean
# updates a user's settings, it will return if the query was successful.
updateUserSettings(settings: UpdateUserSettingsInput!): Boolean
}
schema {
query: RootQuery
mutation: RootMutation
}
`];
module.exports = typeDefs;