Initial commit.

This commit is contained in:
Richard Feldman
2018-04-27 02:00:51 -04:00
commit 345368cbcc
120 changed files with 30805 additions and 0 deletions

8
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.DS_Store
node_modules/
coverage/
npm-debug.log
stats.json
yarn-error.log
data/

21
server/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Ice Services
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

BIN
server/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

31
server/mixins/db.mixin.js Normal file
View File

@@ -0,0 +1,31 @@
"use strict";
const path = require("path");
const mkdir = require("mkdirp").sync;
const DbService = require("moleculer-db");
//process.env.MONGO_URI = "mongodb://localhost/conduit";
module.exports = function(collection) {
if (process.env.MONGO_URI) {
// Mongo adapter
const MongoAdapter = require("moleculer-db-adapter-mongo");
return {
mixins: [DbService],
adapter: new MongoAdapter(process.env.MONGO_URI),
collection
};
}
// --- NeDB fallback DB adapter
// Create data folder
mkdir(path.resolve("./data"));
return {
mixins: [DbService],
adapter: new DbService.MemoryAdapter({ filename: `./data/${collection}.db` })
};
};

View File

@@ -0,0 +1,17 @@
"use strict";
const os = require("os");
module.exports = {
// It will be unique when scale up instances in Docker or on local computer
nodeID: os.hostname().toLowerCase() + "-" + process.pid,
logger: true,
logLevel: "info",
//transporter: "nats://localhost:4222",
cacher: "memory",
metrics: true
};

8612
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
server/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "moleculer-realworld-example-app",
"version": "1.0.0",
"description": "RealWorld example app with Moleculer microservices framework",
"scripts": {
"dev": "moleculer-runner --repl --hot services",
"start": "moleculer-runner",
"deps": "npm-check -u",
"ci": "jest --watch",
"test": "jest --coverage",
"lint": "eslint services",
"docker:build": "docker build -t conduit ."
},
"keywords": [
"microservices",
"moleculer",
"realworld"
],
"author": "",
"devDependencies": {
"eslint": "4.11.0",
"jest": "21.2.1",
"jest-cli": "21.2.1",
"moleculer-repl": "^0.3.0",
"npm-check": "5.5.2"
},
"dependencies": {
"bcrypt": "1.0.3",
"jsonwebtoken": "8.1.0",
"lodash": "4.17.4",
"moleculer": "^0.11.0",
"moleculer-db": "0.7.0",
"moleculer-db-adapter-mongo": "0.1.6",
"moleculer-web": "0.6.0-beta7",
"nats": "0.7.24",
"slug": "0.9.1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/ice-services/moleculer-realworld-example-app.git"
},
"engines": {
"node": ">= 6.x.x"
},
"jest": {
"testEnvironment": "node",
"rootDir": "./services",
"roots": [
"../test"
]
}
}

View File

@@ -0,0 +1,4 @@
{
"main.js": "static/js/main.cd99036f.js",
"main.js.map": "static/js/main.cd99036f.js.map"
}

BIN
server/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

1
server/public/index.html Normal file
View File

@@ -0,0 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="shortcut icon" href="/favicon.ico"><link rel="stylesheet" href="//demo.productionready.io/main.css"><link href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" rel="stylesheet"><link href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic" rel="stylesheet"><title>Conduit</title></head><body><div id="root"></div><script type="text/javascript" src="/static/js/main.41b57957.js"></script></body></html>

BIN
server/rw-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

93
server/server_readme.md Normal file
View File

@@ -0,0 +1,93 @@
# ![RealWorld Example App](rw-logo.png)
> ### [Moleculer](http://moleculer.services/) codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API.
This repo is functionality complete — PRs and issues welcome!
**Live demo on Glitch: https://realworld-moleculer.glitch.me**
Glitch project: https://glitch.com/edit/#!/realworld-moleculer
*[React + Redux](https://github.com/icebob/react-redux-realworld-example-app) front-end UI is included.*
*For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo.*
## Getting started
### To get the Node server running locally:
- Clone this repo
- `npm install` to install all required dependencies
- `npm run dev` to start the local server
- the API is available at http://localhost:3000/api
Alternately, to quickly try out this repo in the cloud, you can
[![Remix on Glitch](https://cdn.glitch.com/2703baf2-b643-4da7-ab91-7ee2a2d00b5b%2Fremix-button.svg)](https://glitch.com/edit/#!/remix/realworld-moleculer)
#### MongoDB persistent store
Basically the services stores data in an NeDB persistent file storage in the `./data` folder. If you have to use MongoDB, set the `MONGO_URI` environment variable.
```
MONGO_URI=mongodb://localhost/conduit
```
#### Multiple instances
You can run multiple instances of services. In this case you need to use a transporter i.e.: [NATS](https://nats.io). NATS is a lightweight & fast message broker. Download it and start with `gnatsd` command. After it started, set the `TRANSPORTER` env variable and start services.
```
TRANSPORTER=nats://localhost:4222
```
### To get the Node server running locally with Docker
1. Checkout the repo `git clone https://github.com/ice-services/moleculer-realworld-example-app.git`
2. `cd moleculer-realworld-example-app`
3. Start with docker-compose: `docker-compose up -d`
It starts all services in separated containers, a NATS server for communication, a MongoDB server for database and a [Traefik](https://traefik.io/) reverse proxy
4. Open the http://docker-ip:3000
5. Scale up services
`docker-compose scale api=3 articles=2 users=2 comments=2 follows=2 favorites=2`
## Code Overview
### Dependencies
- [moleculer](https://github.com/ice-services/moleculer) - Microservices framework for NodeJS
- [moleculer-web](https://github.com/ice-services/moleculer-web) - Official API Gateway service for Moleculer
- [moleculer-db](https://github.com/ice-services/moleculer-db/tree/master/packages/moleculer-db#readme) - Database store service for Moleculer
- [moleculer-db-adapter-mongo](https://github.com/ice-services/moleculer-db/tree/master/packages/moleculer-db-adapter-mongo#readme) - Database store service for MongoDB *(optional)*
- [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken) - For generating JWTs used by authentication
- [bcrypt](https://github.com/kelektiv/node.bcrypt.js) - Hashing user password
- [lodash](https://github.com/lodash/lodash) - Utility library
- [slug](https://github.com/dodo/node-slug) - For encoding titles into a URL-friendly format
- [nats](https://github.com/dodo/node-slug) - [NATS](https://nats.io) transport driver for Moleculer *(optional)*
### Application Structure
- `moleculer.config.js` - Moleculer ServiceBroker configuration file.
- `services/` - This folder contains the services.
- `public/` - This folder contains the front-end static files.
- `data/` - This folder contains the NeDB database files.
## Test
**Tested with [realworld-server-tester](https://github.com/agrison/realworld-server-tester).**
*Local tests is missing currently.*
```
$ npm test
```
In development with watching
```
$ npm run ci
```
## License
This project is available under the [MIT license](https://tldrlegal.com/license/mit-license).
## Contact
Copyright (c) 2016-2017 Ice-Services
[![@ice-services](https://img.shields.io/badge/github-ice--services-green.svg)](https://github.com/ice-services) [![@MoleculerJS](https://img.shields.io/badge/twitter-MoleculerJS-blue.svg)](https://twitter.com/MoleculerJS)

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");
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, 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 || "https://static.productionready.io/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}.*`);
}
}
};