Move stuff into intro/ and advanced/

This commit is contained in:
Richard Feldman
2018-08-05 06:07:14 -04:00
parent d1325d4dbb
commit f6bd524cb6
141 changed files with 53 additions and 19872 deletions

View File

@@ -0,0 +1,171 @@
"use strict";
const _ = require("lodash");
const ApiGateway = require("moleculer-web");
const { UnAuthorizedError } = ApiGateway.Errors;
module.exports = {
name: "api",
mixins: [ApiGateway],
settings: {
port: process.env.PORT || 3000,
routes: [{
path: "/api",
authorization: true,
aliases: {
// Login
"POST /users/login": "users.login",
// Users
"REST /users": "users",
// Current user
"GET /user": "users.me",
"PUT /user": "users.updateMyself",
// Articles
"GET /articles/feed": "articles.feed",
"REST /articles": "articles",
"GET /tags": "articles.tags",
// Comments
"GET /articles/:slug/comments": "articles.comments",
"POST /articles/:slug/comments": "articles.addComment",
"PUT /articles/:slug/comments/:commentID": "articles.updateComment",
"DELETE /articles/:slug/comments/:commentID": "articles.removeComment",
// Favorites
"POST /articles/:slug/favorite": "articles.favorite",
"DELETE /articles/:slug/favorite": "articles.unfavorite",
// Profile
"GET /profiles/:username": "users.profile",
"POST /profiles/:username/follow": "users.follow",
"DELETE /profiles/:username/follow": "users.unfollow",
},
// Disable to call not-mapped actions
mappingPolicy: "restrict",
// Set CORS headers
cors: true,
// Parse body content
bodyParsers: {
json: {
strict: false
},
urlencoded: {
extended: false
}
}
}],
assets: {
folder: "./public"
},
// logRequestParams: "info",
// logResponseData: "info",
onError(req, res, err) {
// Return with the error as JSON object
res.setHeader("Content-type", "application/json; charset=utf-8");
res.writeHead(err.code || 500);
if (err.code == 422) {
let o = {};
err.data.forEach(e => {
let field = e.field.split(".").pop();
o[field] = e.message;
});
res.end(JSON.stringify({ errors: o }, null, 2));
} else {
const errObj = _.pick(err, ["name", "message", "code", "type", "data"]);
res.end(JSON.stringify(errObj, null, 2));
}
this.logResponse(req, res, err? err.ctx : null);
}
},
methods: {
/**
* Authorize the request
*
* @param {Context} ctx
* @param {Object} route
* @param {IncomingRequest} req
* @returns {Promise}
*/
authorize(ctx, route, req) {
let token;
if (req.headers.authorization) {
let type = req.headers.authorization.split(" ")[0];
if (type === "Token" || type === "Bearer")
token = req.headers.authorization.split(" ")[1];
}
return this.Promise.resolve(token)
.then(token => {
if (token) {
// Verify JWT token
return ctx.call("users.resolveToken", { token })
.then(user => {
if (user) {
this.logger.info("Authenticated via JWT: ", user.username);
// Reduce user fields (it will be transferred to other nodes)
ctx.meta.user = _.pick(user, ["_id", "username", "email", "image"]);
ctx.meta.token = token;
}
return user;
})
.catch(err => {
// Ignored because we continue processing if user is not exist
return null;
});
}
})
.then(user => {
if (req.$endpoint.action.auth == "required" && !user)
return this.Promise.reject(new UnAuthorizedError());
});
},
/**
* Convert ValidationError to RealWorld.io result
* @param {*} req
* @param {*} res
* @param {*} err
*/
/*sendError(req, res, err) {
if (err.code == 422) {
res.setHeader("Content-type", "application/json; charset=utf-8");
res.writeHead(422);
let o = {};
err.data.forEach(e => {
let field = e.field.split(".").pop();
o[field] = e.message;
});
return res.end(JSON.stringify({
errors: o
}, null, 2));
}
return this._sendError(req, res, err);
}*/
},
created() {
// Pointer to the original function
//this._sendError = ApiGateway.methods.sendError.bind(this);
}
};

View File

@@ -0,0 +1,614 @@
"use strict";
const { MoleculerClientError } = require("moleculer").Errors;
const { ForbiddenError } = require("moleculer-web").Errors;
const _ = require("lodash");
const slug = require("slug");
const DbService = require("../mixins/db.mixin");
module.exports = {
name: "articles",
mixins: [DbService("articles")],
/**
* Default settings
*/
settings: {
fields: ["_id", "title", "slug", "description", "body", "tagList", "createdAt", "updatedAt", "favorited", "favoritesCount", "author", "comments"],
// Populates
populates: {
author: {
action: "users.get",
params: {
fields: ["username", "bio", "image"]
}
},
comments: {
action: "comments.get",
params: {
fields: ["_id", "body", "author"],
populates: ["author"]
}
},
favorited(ids, articles, rule, ctx) {
if (ctx.meta.user)
return this.Promise.all(articles.map(article => ctx.call("favorites.has", { article: article._id.toString(), user: ctx.meta.user._id.toString() }).then(res => article.favorited = res)));
else {
articles.forEach(article => article.favorited = false);
return this.Promise.resolve();
}
},
favoritesCount(ids, articles, rule, ctx) {
return this.Promise.all(articles.map(article => ctx.call("favorites.count", { article: article._id.toString() }).then(count => article.favoritesCount = count)));
}
},
// Validation schema for new entities
entityValidator: {
title: { type: "string", min: 1 },
description: { type: "string", min: 1 },
body: { type: "string", min: 1 },
tagList: { type: "array", items: "string", optional: true },
}
},
/**
* Actions
*/
actions: {
/**
* Create a new article.
* Auth is required!
*
* @actions
* @param {Object} article - Article entity
*
* @returns {Object} Created entity
*/
create: {
auth: "required",
params: {
article: { type: "object" }
},
handler(ctx) {
let entity = ctx.params.article;
return this.validateEntity(entity)
.then(() => {
entity.slug = slug(entity.title, { lower: true }) + "-" + (Math.random() * Math.pow(36, 6) | 0).toString(36);
entity.author = ctx.meta.user._id.toString();
entity.createdAt = new Date();
entity.updatedAt = new Date();
return this.adapter.insert(entity)
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"]}, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user))
.then(json => this.entityChanged("created", json, ctx).then(() => json));
});
}
},
/**
* Update an article.
* Auth is required!
*
* @actions
* @param {String} id - Article ID
* @param {Object} article - Article modified fields
*
* @returns {Object} Updated entity
*/
update: {
auth: "required",
params: {
id: { type: "string" },
article: { type: "object", props: {
title: { type: "string", min: 1, optional: true },
description: { type: "string", min: 1, optional: true },
body: { type: "string", min: 1, optional: true },
tagList: { type: "array", items: "string", optional: true },
} }
},
handler(ctx) {
let newData = ctx.params.article;
newData.updatedAt = new Date();
// the 'id' is the slug
return this.Promise.resolve(ctx.params.id)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
if (article.author !== ctx.meta.user._id.toString())
return this.Promise.reject(new ForbiddenError());
const update = {
"$set": newData
};
return this.adapter.updateById(article._id, update);
})
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"]}, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user))
.then(json => this.entityChanged("updated", json, ctx).then(() => json));
}
},
/**
* List articles with pagination.
*
* @actions
* @param {String} tag - Filter for 'tag'
* @param {String} author - Filter for author ID
* @param {String} favorited - Filter for favorited author
* @param {Number} limit - Pagination limit
* @param {Number} offset - Pagination offset
*
* @returns {Object} List of articles
*/
list: {
cache: {
keys: ["#token", "tag", "author", "favorited", "limit", "offset"]
},
params: {
tag: { type: "string", optional: true },
author: { type: "string", optional: true },
favorited: { type: "string", optional: true },
limit: { type: "number", optional: true, convert: true },
offset: { type: "number", optional: true, convert: true },
},
handler(ctx) {
const limit = ctx.params.limit ? Number(ctx.params.limit) : 20;
const offset = ctx.params.offset ? Number(ctx.params.offset) : 0;
let params = {
limit,
offset,
sort: ["-createdAt"],
populate: ["author", "favorited", "favoritesCount"],
query: {}
};
let countParams;
if (ctx.params.tag)
params.query.tagList = {"$in" : [ctx.params.tag]};
return this.Promise.resolve()
.then(() => {
if (ctx.params.author) {
return ctx.call("users.find", { query: { username: ctx.params.author } })
.then(users => {
if (users.length == 0)
return this.Promise.reject(new MoleculerClientError("Author not found"));
params.query.author = users[0]._id;
});
}
if (ctx.params.favorited) {
return ctx.call("users.find", { query: { username: ctx.params.favorited } })
.then(users => {
if (users.length == 0)
return this.Promise.reject(new MoleculerClientError("Author not found"));
return users[0]._id;
})
.then(user => {
return ctx.call("favorites.find", { fields: ["article"], query: { user }})
.then(list => {
params.query._id = { $in: list.map(o => o.article) };
});
});
}
})
.then(() => {
countParams = Object.assign({}, params);
// Remove pagination params
if (countParams && countParams.limit)
countParams.limit = null;
if (countParams && countParams.offset)
countParams.offset = null;
})
.then(() => this.Promise.all([
// Get rows
this.adapter.find(params),
// Get count of all rows
this.adapter.count(countParams)
])).then(res => {
return this.transformDocuments(ctx, params, res[0])
.then(docs => this.transformResult(ctx, docs, ctx.meta.user))
.then(r => {
r.articlesCount = res[1];
return r;
});
});
}
},
/**
* List articles from followed authors.
* Auth is required!
*
* @actions
* @param {Number} limit - Pagination limit
* @param {Number} offset - Pagination offset
*
* @returns {Object} List of articles
*/
feed: {
auth: "required",
cache: {
keys: ["#token", "limit", "offset"]
},
params: {
limit: { type: "number", optional: true, convert: true },
offset: { type: "number", optional: true, convert: true },
},
handler(ctx) {
const limit = ctx.params.limit ? Number(ctx.params.limit) : 20;
const offset = ctx.params.offset ? Number(ctx.params.offset) : 0;
let params = {
limit,
offset,
sort: ["-createdAt"],
populate: ["author", "favorited", "favoritesCount"],
query: {}
};
let countParams;
return this.Promise.resolve()
.then(() => {
return ctx.call("follows.find", { fields: ["follow"], query: { user: ctx.meta.user._id.toString() } })
.then(list => {
const authors = _.uniq(_.compact(_.flattenDeep(list.map(o => o.follow))));
params.query.author = {"$in" : authors};
});
})
.then(() => {
countParams = Object.assign({}, params);
// Remove pagination params
if (countParams && countParams.limit)
countParams.limit = null;
if (countParams && countParams.offset)
countParams.offset = null;
})
.then(() => this.Promise.all([
// Get rows
this.adapter.find(params),
// Get count of all rows
this.adapter.count(countParams)
])).then(res => {
return this.transformDocuments(ctx, params, res[0])
.then(docs => this.transformResult(ctx, docs, ctx.meta.user))
.then(r => {
r.articlesCount = res[1];
return r;
});
});
}
},
/**
* Get an article by slug
*
* @actions
* @param {String} id - Article slug
*
* @returns {Object} Article entity
*/
get: {
cache: {
keys: ["#token", "id"]
},
params: {
id: { type: "string" }
},
handler(ctx) {
return this.findBySlug(ctx.params.id)
.then(entity => {
if (!entity)
return this.Promise.reject(new MoleculerClientError("Article not found!", 404));
return entity;
})
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"] }, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user));
}
},
/**
* Remove an article by slug
* Auth is required!
*
* @actions
* @param {String} id - Article slug
*
* @returns {Number} Count of removed articles
*/
remove: {
auth: "required",
params: {
id: { type: "any" }
},
handler(ctx) {
return this.findBySlug(ctx.params.id)
.then(entity => {
if (!entity)
return this.Promise.reject(new MoleculerClientError("Article not found!", 404));
if (entity.author !== ctx.meta.user._id.toString())
return this.Promise.reject(new ForbiddenError());
return this.adapter.removeById(entity._id)
.then(() => ctx.call("favorites.removeByArticle", { article: entity._id }))
.then(json => this.entityChanged("removed", json, ctx).then(() => json));
});
}
},
/**
* Favorite an article
* Auth is required!
*
* @actions
* @param {String} id - Article slug
*
* @returns {Object} Updated article
*/
favorite: {
auth: "required",
params: {
slug: { type: "string" }
},
handler(ctx) {
return this.Promise.resolve(ctx.params.slug)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
return ctx.call("favorites.add", { article: article._id.toString(), user: ctx.meta.user._id.toString() }).then(() => article);
})
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"] }, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user));
}
},
/**
* Unfavorite an article
* Auth is required!
*
* @actions
* @param {String} id - Article slug
*
* @returns {Object} Updated article
*/
unfavorite: {
auth: "required",
params: {
slug: { type: "string" }
},
handler(ctx) {
return this.Promise.resolve(ctx.params.slug)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
return ctx.call("favorites.delete", { article: article._id.toString(), user: ctx.meta.user._id.toString() }).then(() => article);
})
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"] }, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user));
}
},
/**
* Get list of available tags
*
* @returns {Object} Tag list
*/
tags: {
cache: {
keys: []
},
handler(ctx) {
return this.Promise.resolve()
.then(() => this.adapter.find({ fields: ["tagList"], sort: ["createdAt"] }))
.then(list => {
return _.uniq(_.compact(_.flattenDeep(list.map(o => o.tagList))));
})
.then(tags => ({ tags }));
}
},
/**
* Get all comments of an article.
*
* @actions
* @param {String} slug - Article slug
*
* @returns {Object} Comment list
*
*/
comments: {
cache: {
keys: ["#token", "slug"]
},
params: {
slug: { type: "string" }
},
handler(ctx) {
return this.Promise.resolve(ctx.params.slug)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
return ctx.call("comments.list", { article: article._id.toString() });
});
}
},
/**
* Add a new comment to an article.
* Auth is required!
*
* @actions
* @param {String} slug - Article slug
* @param {Object} comment - Comment fields
*
* @returns {Object} Comment entity
*/
addComment: {
auth: "required",
params: {
slug: { type: "string" },
comment: { type: "object" }
},
handler(ctx) {
return this.Promise.resolve(ctx.params.slug)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
return ctx.call("comments.create", { article: article._id.toString(), comment: ctx.params.comment });
});
}
},
/**
* Update a comment.
* Auth is required!
*
* @actions
* @param {String} slug - Article slug
* @param {String} commentID - Comment ID
* @param {Object} comment - Comment fields
*
* @returns {Object} Comment entity
*/
updateComment: {
auth: "required",
params: {
slug: { type: "string" },
commentID: { type: "string" },
comment: { type: "object" }
},
handler(ctx) {
return this.Promise.resolve(ctx.params.slug)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
return ctx.call("comments.update", { id: ctx.params.commentID, comment: ctx.params.comment });
});
}
},
/**
* Remove a comment.
* Auth is required!
*
* @actions
* @param {String} slug - Article slug
* @param {String} commentID - Comment ID
*
* @returns {Number} Count of removed comment
*/
removeComment: {
auth: "required",
params: {
slug: { type: "string" },
commentID: { type: "string" }
},
handler(ctx) {
return this.Promise.resolve(ctx.params.slug)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found"));
return ctx.call("comments.remove", { id: ctx.params.commentID });
});
}
}
},
/**
* Methods
*/
methods: {
/**
* Find an article by slug
*
* @param {String} slug - Article slug
*
* @results {Object} Promise<Article
*/
findBySlug(slug) {
return this.adapter.findOne({ slug });
},
/**
* Transform the result entities to follow the RealWorld API spec
*
* @param {Context} ctx
* @param {Array} entities
* @param {Object} user - Logged in user
*/
transformResult(ctx, entities, user) {
if (Array.isArray(entities)) {
return this.Promise.map(entities, item => this.transformEntity(ctx, item, user))
.then(articles => ({ articles }));
} else {
return this.transformEntity(ctx, entities, user)
.then(article => ({ article }));
}
},
/**
* Transform a result entity to follow the RealWorld API spec
*
* @param {Context} ctx
* @param {Object} entity
* @param {Object} user - Logged in user
*/
transformEntity(ctx, entity, user) {
if (!entity) return this.Promise.resolve();
return this.Promise.resolve(entity);
}
},
events: {
"cache.clean.articles"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.users"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.comments"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.follows"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.favorites"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
}
}
};

View File

@@ -0,0 +1,266 @@
"use strict";
const { ForbiddenError } = require("moleculer-web").Errors;
const DbService = require("../mixins/db.mixin");
module.exports = {
name: "comments",
mixins: [DbService("comments")],
/**
* Default settings
*/
settings: {
fields: ["_id", "author", "article", "body", "createdAt", "updatedAt"],
populates: {
"author": {
action: "users.get",
params: {
fields: ["_id", "username", "bio", "image"]
}
}
},
entityValidator: {
article: { type: "string" },
body: { type: "string", min: 1 },
}
},
/**
* Actions
*/
actions: {
/**
* Create a comment.
* Auth is required!
*
* @actions
* @param {String} article - Article ID
* @param {Object} comment - Comment entity
*
* @returns {Object} Created comment entity
*/
create: {
auth: "required",
params: {
article: { type: "string" },
comment: { type: "object" }
},
handler(ctx) {
let entity = ctx.params.comment;
entity.article = ctx.params.article;
entity.author = ctx.meta.user._id.toString();
return this.validateEntity(entity)
.then(() => {
entity.createdAt = new Date();
entity.updatedAt = new Date();
return this.adapter.insert(entity)
.then(doc => this.transformDocuments(ctx, { populate: ["author"]}, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user))
.then(json => this.entityChanged("created", json, ctx).then(() => json));
});
}
},
/**
* Update a comment.
* Auth is required!
*
* @actions
* @param {String} id - Comment ID
* @param {Object} comment - Comment modified fields
*
* @returns {Object} Updated comment entity
*/
update: {
auth: "required",
params: {
id: { type: "string" },
comment: { type: "object", props: {
body: { type: "string", min: 1 },
} }
},
handler(ctx) {
let newData = ctx.params.comment;
newData.updatedAt = new Date();
return this.getById(ctx.params.id)
.then(comment => {
if (comment.author !== ctx.meta.user._id.toString())
return this.Promise.reject(new ForbiddenError());
const update = {
"$set": newData
};
return this.adapter.updateById(ctx.params.id, update);
})
.then(doc => this.transformDocuments(ctx, { populate: ["author"]}, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user))
.then(json => this.entityChanged("updated", json, ctx).then(() => json));
}
},
/**
* List of comments by article.
*
* @actions
* @param {String} article - Article ID
* @param {Number} limit - Pagination limit
* @param {Number} offset - Pagination offset
*
* @returns {Object} List of comments
*/
list: {
cache: {
keys: ["#token", "article", "limit", "offset"]
},
params: {
article: { type: "string" },
limit: { type: "number", optional: true, convert: true },
offset: { type: "number", optional: true, convert: true },
},
handler(ctx) {
const limit = ctx.params.limit ? Number(ctx.params.limit) : 20;
const offset = ctx.params.offset ? Number(ctx.params.offset) : 0;
let params = {
limit,
offset,
sort: ["-createdAt"],
populate: ["author"],
query: {
article: ctx.params.article
}
};
let countParams;
return this.Promise.resolve()
.then(() => {
countParams = Object.assign({}, params);
// Remove pagination params
if (countParams && countParams.limit)
countParams.limit = null;
if (countParams && countParams.offset)
countParams.offset = null;
})
.then(() => this.Promise.all([
// Get rows
this.adapter.find(params),
// Get count of all rows
this.adapter.count(countParams)
])).then(res => {
return this.transformDocuments(ctx, params, res[0])
.then(docs => this.transformResult(ctx, docs, ctx.meta.user))
.then(r => {
r.commentsCount = res[1];
return r;
});
});
}
},
/**
* Remove a comment
* Auth is required!
*
* @actions
* @param {String} id - Comment ID
*
* @returns {Number} Count of removed comments
*/
remove: {
auth: "required",
params: {
id: { type: "any" }
},
handler(ctx) {
return this.getById(ctx.params.id)
.then(comment => {
if (comment.author !== ctx.meta.user._id.toString())
return this.Promise.reject(new ForbiddenError());
return this.adapter.removeById(ctx.params.id)
.then(json => this.entityChanged("updated", json, ctx).then(() => json));
});
}
}
},
/**
* Methods
*/
methods: {
/**
* Transform the result entities to follow the RealWorld API spec
*
* @param {Context} ctx
* @param {Array} entities
* @param {Object} user - Logged in user
*/
transformResult(ctx, entities, user) {
if (Array.isArray(entities)) {
return this.Promise.map(entities, item => this.transformEntity(ctx, item, user))
.then(comments => ({ comments }));
} else {
return this.transformEntity(ctx, entities, user)
.then(comment => ({ comment }));
}
},
/**
* Transform a result entity to follow the RealWorld API spec
*
* @param {Context} ctx
* @param {Object} entity
* @param {Object} user - Logged in user
*/
transformEntity(ctx, entity, loggedInUser) {
if (!entity) return this.Promise.resolve();
return this.Promise.resolve(entity)
.then(entity => {
entity.id = entity._id;
if (loggedInUser) {
return ctx.call("follows.has", { user: loggedInUser._id.toString(), follow: entity.author._id })
.then(res => {
entity.author.following = res;
return entity;
});
}
entity.author.following = false;
return entity;
});
}
},
events: {
"cache.clean.comments"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.users"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.follows"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.articles"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
}
}
};

View File

@@ -0,0 +1,177 @@
"use strict";
const { MoleculerClientError } = require("moleculer").Errors;
const DbService = require("../mixins/db.mixin");
module.exports = {
name: "favorites",
mixins: [DbService("favorites")],
/**
* Default settings
*/
settings: {
},
/**
* Actions
*/
actions: {
/**
* Create a new favorite record
*
* @actions
*
* @param {String} article - Article ID
* @param {String} user - User ID
* @returns {Object} Created favorite record
*/
add: {
params: {
article: { type: "string" },
user: { type: "string" },
},
handler(ctx) {
const { article, user } = ctx.params;
return this.findByArticleAndUser(article, user)
.then(item => {
if (item)
return this.Promise.reject(new MoleculerClientError("Articles has already favorited"));
return this.adapter.insert({ article, user, createdAt: new Date() })
.then(json => this.entityChanged("created", json, ctx).then(() => json));
});
}
},
/**
* Check the given 'article' is followed by 'user'.
*
* @actions
*
* @param {String} article - Article ID
* @param {String} user - User ID
* @returns {Boolean}
*/
has: {
cache: {
keys: ["article", "user"]
},
params: {
article: { type: "string" },
user: { type: "string" },
},
handler(ctx) {
const { article, user } = ctx.params;
return this.findByArticleAndUser(article, user)
.then(item => !!item);
}
},
/**
* Count of favorites.
*
* @actions
*
* @param {String?} article - Article ID
* @param {String?} user - User ID
* @returns {Number}
*/
count: {
cache: {
keys: ["article", "user"]
},
params: {
article: { type: "string", optional: true },
user: { type: "string", optional: true },
},
handler(ctx) {
let query = {};
if (ctx.params.article)
query = { article: ctx.params.article };
if (ctx.params.user)
query = { user: ctx.params.user };
return this.adapter.count({ query });
}
},
/**
* Delete a favorite record
*
* @actions
*
* @param {String} article - Article ID
* @param {String} user - User ID
* @returns {Number} Count of removed records
*/
delete: {
params: {
article: { type: "string" },
user: { type: "string" },
},
handler(ctx) {
const { article, user } = ctx.params;
return this.findByArticleAndUser(article, user)
.then(item => {
if (!item)
return this.Promise.reject(new MoleculerClientError("Articles has not favorited yet"));
return this.adapter.removeById(item._id)
.then(json => this.entityChanged("removed", json, ctx).then(() => json));
});
}
},
/**
* Remove all favorites by article
*
* @actions
*
* @param {String} article - Article ID
* @returns {Number} Count of removed records
*/
removeByArticle: {
params: {
article: { type: "string" }
},
handler(ctx) {
return this.adapter.removeMany(ctx.params);
}
}
},
/**
* Methods
*/
methods: {
/**
* Find the first favorite record by 'article' or 'user'
* @param {String} article - Article ID
* @param {String} user - User ID
*/
findByArticleAndUser(article, user) {
return this.adapter.findOne({ article, user });
},
},
events: {
"cache.clean.favorites"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.users"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.articles"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
}
}
};

View File

@@ -0,0 +1,153 @@
"use strict";
const { MoleculerClientError } = require("moleculer").Errors;
const DbService = require("../mixins/db.mixin");
module.exports = {
name: "follows",
mixins: [DbService("follows")],
/**
* Default settings
*/
settings: {
},
/**
* Actions
*/
actions: {
/**
* Create a new following record
*
* @actions
*
* @param {String} user - Follower username
* @param {String} follow - Followee username
* @returns {Object} Created following record
*/
add: {
params: {
user: { type: "string" },
follow: { type: "string" },
},
handler(ctx) {
const { follow, user } = ctx.params;
return this.findByFollowAndUser(follow, user)
.then(item => {
if (item)
return this.Promise.reject(new MoleculerClientError("User has already followed"));
return this.adapter.insert({ follow, user, createdAt: new Date() })
.then(json => this.entityChanged("created", json, ctx).then(() => json));
});
}
},
/**
* Check the given 'follow' user is followed by 'user' user.
*
* @actions
*
* @param {String} user - Follower username
* @param {String} follow - Followee username
* @returns {Boolean}
*/
has: {
cache: {
keys: ["article", "user"]
},
params: {
user: { type: "string" },
follow: { type: "string" },
},
handler(ctx) {
return this.findByFollowAndUser(ctx.params.follow, ctx.params.user)
.then(item => !!item);
}
},
/**
* Count of following.
*
* @actions
*
* @param {String?} user - Follower username
* @param {String?} follow - Followee username
* @returns {Number}
*/
count: {
cache: {
keys: ["article", "user"]
},
params: {
follow: { type: "string", optional: true },
user: { type: "string", optional: true },
},
handler(ctx) {
let query = {};
if (ctx.params.follow)
query = { follow: ctx.params.follow };
if (ctx.params.user)
query = { user: ctx.params.user };
return this.adapter.count({ query });
}
},
/**
* Delete a following record
*
* @actions
*
* @param {String} user - Follower username
* @param {String} follow - Followee username
* @returns {Number} Count of removed records
*/
delete: {
params: {
user: { type: "string" },
follow: { type: "string" },
},
handler(ctx) {
const { follow, user } = ctx.params;
return this.findByFollowAndUser(follow, user)
.then(item => {
if (!item)
return this.Promise.reject(new MoleculerClientError("User has not followed yet"));
return this.adapter.removeById(item._id)
.then(json => this.entityChanged("removed", json, ctx).then(() => json));
});
}
}
},
/**
* Methods
*/
methods: {
/**
* Find the first following record by 'follow' or 'user'
* @param {String} follow - Followee username
* @param {String} user - Follower username
*/
findByFollowAndUser(follow, user) {
return this.adapter.findOne({ follow, user });
},
},
events: {
"cache.clean.follows"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.users"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
}
}
};

View File

@@ -0,0 +1,96 @@
let _ = require("lodash");
module.exports = {
name: "metrics",
events: {
"metrics.trace.span.start"(payload) {
this.requests[payload.id] = payload;
payload.spans = [];
if (payload.parent) {
let parent = this.requests[payload.parent];
if (parent)
parent.spans.push(payload.id);
}
},
"metrics.trace.span.finish"(payload) {
let item = this.requests[payload.id];
_.assign(item, payload);
if (!payload.parent)
this.printRequest(payload.id);
}
},
methods: {
printRequest(id) {
let main = this.requests[id];
let w = 73;
let r = _.repeat;
let gw = 35;
let maxTitle = w - 2 - 2 - gw - 2 - 1;
this.logger.info(["┌", r("─", w-2), "┐"].join(""));
let printSpanTime = (span) => {
let time = span.duration.toFixed(2);
let maxActionName = maxTitle - (span.level-1) * 2 - time.length - 3 - (span.fromCache ? 2 : 0) - (span.remoteCall ? 2 : 0) - (span.error ? 2 : 0);
let actionName = span.action ? span.action.name : "";
if (actionName.length > maxActionName)
actionName = _.truncate(span.action.name, { length: maxActionName });
let strAction = [
r(" ", span.level - 1),
actionName,
r(" ", maxActionName - actionName.length + 1),
span.fromCache ? "* " : "",
span.remoteCall ? "» " : "",
span.error ? "× " : "",
time,
"ms "
].join("");
if (span.startTime == null || span.endTime == null) {
this.logger.info(strAction + "! Missing invoke !");
return;
}
let gstart = (span.startTime - main.startTime) / (main.endTime - main.startTime) * 100;
let gstop = (span.endTime - main.startTime) / (main.endTime - main.startTime) * 100;
if (_.isNaN(gstart) && _.isNaN(gstop)) {
gstart = 0;
gstop = 100;
}
let p1 = Math.round(gw * gstart / 100);
let p2 = Math.round(gw * gstop / 100) - p1;
let p3 = Math.max(gw - (p1 + p2), 0);
let gauge = [
"[",
r(".", p1),
r("■", p2),
r(".", p3),
"]"
].join("");
this.logger.info("│ " + strAction + gauge + " │");
if (span.spans.length > 0)
span.spans.forEach(spanID => printSpanTime(this.requests[spanID]));
};
printSpanTime(main);
this.logger.info(["└", r("─", w-2), "┘"].join(""));
}
},
created() {
this.requests = {};
}
};

View File

@@ -0,0 +1,390 @@
"use strict";
const { MoleculerClientError } = require("moleculer").Errors;
//const crypto = require("crypto");
const bcrypt = require("bcrypt-nodejs");
const jwt = require("jsonwebtoken");
const DbService = require("../mixins/db.mixin");
module.exports = {
name: "users",
mixins: [DbService("users")],
/**
* Default settings
*/
settings: {
/** Secret for JWT */
JWT_SECRET: process.env.JWT_SECRET || "jwt-conduit-secret",
/** Public fields */
fields: ["_id", "username", "email", "bio", "image"],
/** Validator schema for entity */
entityValidator: {
username: { type: "string", min: 2, pattern: /^[a-zA-Z0-9]+$/ },
password: { type: "string", min: 6 },
email: { type: "email" },
bio: { type: "string", optional: true },
image: { type: "string", optional: true },
}
},
/**
* Actions
*/
actions: {
/**
* Register a new user
*
* @actions
* @param {Object} user - User entity
*
* @returns {Object} Created entity & token
*/
create: {
params: {
user: { type: "object" }
},
handler(ctx) {
let entity = ctx.params.user;
return this.validateEntity(entity)
.then(() => {
if (entity.username)
return this.adapter.findOne({ username: entity.username })
.then(found => {
if (found)
return Promise.reject(new MoleculerClientError("Username is exist!", 422, "", [{ field: "username", message: "is exist"}]));
});
})
.then(() => {
if (entity.email)
return this.adapter.findOne({ email: entity.email })
.then(found => {
if (found)
return Promise.reject(new MoleculerClientError("Email is exist!", 422, "", [{ field: "email", message: "is exist"}]));
});
})
.then(() => {
entity.password = bcrypt.hashSync(entity.password, bcrypt.genSaltSync(10));
entity.bio = entity.bio || "";
entity.image = entity.image || null;
entity.createdAt = new Date();
return this.adapter.insert(entity)
.then(doc => this.transformDocuments(ctx, {}, doc))
.then(user => this.transformEntity(user, true, ctx.meta.token))
.then(json => this.entityChanged("created", json, ctx).then(() => json));
});
}
},
/**
* Login with username & password
*
* @actions
* @param {Object} user - User credentials
*
* @returns {Object} Logged in user with token
*/
login: {
params: {
user: { type: "object", props: {
email: { type: "email" },
password: { type: "string", min: 1 }
}}
},
handler(ctx) {
const { email, password } = ctx.params.user;
return this.Promise.resolve()
.then(() => this.adapter.findOne({ email }))
.then(user => {
if (!user)
return this.Promise.reject(new MoleculerClientError("Email or password is invalid!", 422, "", [{ field: "email", message: "is not found"}]));
return bcrypt.compare(password, user.password).then(res => {
if (!res)
return Promise.reject(new MoleculerClientError("Wrong password!", 422, "", [{ field: "email", message: "is not found"}]));
// Transform user entity (remove password and all protected fields)
return this.transformDocuments(ctx, {}, user);
});
})
.then(user => this.transformEntity(user, true, ctx.meta.token));
}
},
/**
* Get user by JWT token (for API GW authentication)
*
* @actions
* @param {String} token - JWT token
*
* @returns {Object} Resolved user
*/
resolveToken: {
cache: {
keys: ["token"],
ttl: 60 * 60 // 1 hour
},
params: {
token: "string"
},
handler(ctx) {
return new this.Promise((resolve, reject) => {
jwt.verify(ctx.params.token, this.settings.JWT_SECRET, (err, decoded) => {
if (err)
return reject(err);
resolve(decoded);
});
})
.then(decoded => {
if (decoded.id)
return this.getById(decoded.id);
});
}
},
/**
* Get current user entity.
* Auth is required!
*
* @actions
*
* @returns {Object} User entity
*/
me: {
auth: "required",
cache: {
keys: ["#token"]
},
handler(ctx) {
return this.getById(ctx.meta.user._id)
.then(user => {
if (!user)
return this.Promise.reject(new MoleculerClientError("User not found!", 400));
return this.transformDocuments(ctx, {}, user);
})
.then(user => this.transformEntity(user, true, ctx.meta.token));
}
},
/**
* Update current user entity.
* Auth is required!
*
* @actions
*
* @param {Object} user - Modified fields
* @returns {Object} User entity
*/
updateMyself: {
auth: "required",
params: {
user: { type: "object", props: {
username: { type: "string", min: 2, optional: true, pattern: /^[a-zA-Z0-9]+$/ },
password: { type: "string", min: 6, optional: true },
email: { type: "email", optional: true },
bio: { type: "string", optional: true },
image: { type: "string", optional: true },
}}
},
handler(ctx) {
const newData = ctx.params.user;
return this.Promise.resolve()
.then(() => {
if (newData.username)
return this.adapter.findOne({ username: newData.username })
.then(found => {
if (found && found._id.toString() !== ctx.meta.user._id.toString())
return Promise.reject(new MoleculerClientError("Username is exist!", 422, "", [{ field: "username", message: "is exist"}]));
});
})
.then(() => {
if (newData.email)
return this.adapter.findOne({ email: newData.email })
.then(found => {
if (found && found._id.toString() !== ctx.meta.user._id.toString())
return Promise.reject(new MoleculerClientError("Email is exist!", 422, "", [{ field: "email", message: "is exist"}]));
});
})
.then(() => {
newData.updatedAt = new Date();
const update = {
"$set": newData
};
return this.adapter.updateById(ctx.meta.user._id, update);
})
.then(doc => this.transformDocuments(ctx, {}, doc))
.then(user => this.transformEntity(user, true, ctx.meta.token))
.then(json => this.entityChanged("updated", json, ctx).then(() => json));
}
},
/**
* Get a user profile.
*
* @actions
*
* @param {String} username - Username
* @returns {Object} User entity
*/
profile: {
cache: {
keys: ["#token", "username"]
},
params: {
username: { type: "string" }
},
handler(ctx) {
return this.adapter.findOne({ username: ctx.params.username })
.then(user => {
if (!user)
return this.Promise.reject(new MoleculerClientError("User not found!", 404));
return this.transformDocuments(ctx, {}, user);
})
.then(user => this.transformProfile(ctx, user, ctx.meta.user));
}
},
/**
* Follow a user
* Auth is required!
*
* @actions
*
* @param {String} username - Followed username
* @returns {Object} Current user entity
*/
follow: {
auth: "required",
params: {
username: { type: "string" }
},
handler(ctx) {
return this.adapter.findOne({ username: ctx.params.username })
.then(user => {
if (!user)
return this.Promise.reject(new MoleculerClientError("User not found!", 404));
return ctx.call("follows.add", { user: ctx.meta.user._id.toString(), follow: user._id.toString() })
.then(() => this.transformDocuments(ctx, {}, user));
})
.then(user => this.transformProfile(ctx, user, ctx.meta.user));
}
},
/**
* Unfollow a user
* Auth is required!
*
* @actions
*
* @param {String} username - Unfollowed username
* @returns {Object} Current user entity
*/
unfollow: {
auth: "required",
params: {
username: { type: "string" }
},
handler(ctx) {
return this.adapter.findOne({ username: ctx.params.username })
.then(user => {
if (!user)
return this.Promise.reject(new MoleculerClientError("User not found!", 404));
return ctx.call("follows.delete", { user: ctx.meta.user._id.toString(), follow: user._id.toString() })
.then(() => this.transformDocuments(ctx, {}, user));
})
.then(user => this.transformProfile(ctx, user, ctx.meta.user));
}
}
},
/**
* Methods
*/
methods: {
/**
* Generate a JWT token from user entity
*
* @param {Object} user
*/
generateJWT(user) {
const today = new Date();
const exp = new Date(today);
exp.setDate(today.getDate() + 60);
return jwt.sign({
id: user._id,
username: user.username,
exp: Math.floor(exp.getTime() / 1000)
}, this.settings.JWT_SECRET);
},
/**
* Transform returned user entity. Generate JWT token if neccessary.
*
* @param {Object} user
* @param {Boolean} withToken
*/
transformEntity(user, withToken, token) {
if (user) {
//user.image = user.image || "https://www.gravatar.com/avatar/" + crypto.createHash("md5").update(user.email).digest("hex") + "?d=robohash";
user.image = user.image || "";
if (withToken)
user.token = token || this.generateJWT(user);
}
return { user };
},
/**
* Transform returned user entity as profile.
*
* @param {Context} ctx
* @param {Object} user
* @param {Object?} loggedInUser
*/
transformProfile(ctx, user, loggedInUser) {
//user.image = user.image || "https://www.gravatar.com/avatar/" + crypto.createHash("md5").update(user.email).digest("hex") + "?d=robohash";
user.image = user.image || "/assets/images/smiley-cyrus.jpg";
if (loggedInUser) {
return ctx.call("follows.has", { user: loggedInUser._id.toString(), follow: user._id.toString() })
.then(res => {
user.following = res;
return { profile: user };
});
}
user.following = false;
return { profile: user };
}
},
events: {
"cache.clean.users"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.follows"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
}
}
};