User
Model
When you’re talking about user you may find plenty things of properties he could have.
Let’s check out main ones:
name- aka username;email- unique user e-mail used for local login;password- user password’s hash for local login;status- user could be “active”, “not-verified”, “banned”, etc. Best practice is using predefined status codes.
Now we’re ready to create our first model.
All models will land inserver/modelsdirectory, don’t forget to create module alias for it:1
2"@models": "./server/models",
"@routes": "./server/routes"
To work with model from outside we have to define routes which can handle request and modify documents. That’s why we created module alias for server/routes directory. We’ll be back soon to describe it behaviour.
Let’s look at user.js model:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138const c = require('@engine/constants')
const mongoose = require('mongoose')
const crypto = require('crypto')
const bcrypt = require('bcrypt')
const flatten = require('flat')
const _ = require('lodash')
const validateLocalStrategyProperty = function (property) {
return property && property.length
}
const validateLocalStrategyPassword = function (password) {
return password && password.length >= 6
}
const userSchema = mongoose.Schema({
name: {
type: String,
trim: true,
unique: true,
index: true,
default: '',
validate: [validateLocalStrategyProperty, c.E.REQUIRED]
},
local: {
email: {
type: String,
trim: true,
index: true,
lowerCase: true,
default: '',
unique: true,
match: [/.+@.+\..+/, c.E.NOT_VALID],
validate: [validateLocalStrategyProperty, c.E.REQUIRED]
},
password: {
type: String,
default: '',
validate: [validateLocalStrategyPassword, [c.E.MIN_LENGTH, 6]]
}
},
profile: {
name: {type: String},
gender: {type: String},
picture: {type: String}
},
status: {
type: Number,
default: 1
}
}, {
timestamps: true,
toObject: {
virtuals: true
},
toJSON: {
virtuals: true
}
})
async function protectPassword (next) {
if (typeof this.getUpdate === 'function') {
if (this.getUpdate()['local.password']) {
this.getUpdate()['local.password'] = await bcrypt.hash(this.getUpdate()['local.password'], 12)
}
} else {
if (this.isModified('local.password')) {
this.local.password = await bcrypt.hash(this.local.password, 12)
}
}
return next()
}
userSchema.pre('save', protectPassword)
userSchema.pre('update', protectPassword)
userSchema.methods.verifyPassword = function (password) {
return bcrypt.compare(password, this.local.password)
}
userSchema.methods.verifyPasswordSync = function (password) {
return bcrypt.compareSync(password, this.local.password)
}
userSchema.virtual('avatar').get(function () {
if (this.profile && this.profile.picture) {
return this.profile.picture
}
const getRandomUserAvatarId = str => {
let c = 0
for (let i = 0; i < str.length; i++) {
c += str.charCodeAt(i)
}
return c % 100
}
const email = this.local.email
if (!email) {
const g = this.profile && this.profile.gender === 'female' ? 'women' : 'men'
return `https://randomuser.me/api/portraits/thumb/${g}/${getRandomUserAvatarId(this.local.name)}.jpg`
} else {
const hash = crypto.createHash('md5').update(email).digest('hex')
return `https://gravatar.com/avatar/${hash}?s=64&d=wavatar`
}
})
userSchema.statics.updateUser = function (req, res, next) {
let values = _.pick(req.body, _.keys(userSchema.paths))
if (values._id) delete values._id
values = _.pickBy(flatten(values), _.identity)
return this.findById(req.params.id)
.then(user => {
if (
((req.body.passNew && req.body.passNew.length) ||
(req.body.passNewConfirm && req.body.passNewConfirm.length)) &&
req.body.passNew !== req.body.passNewConfirm) {
user.invalidate('passNew', c.E.NOT_MATCH)
throw user.invalidate('passNewConfirm', c.E.NOT_MATCH)
}
if (req.body.passNew) {
if (_.isUndefined(values['local.password'])) {
throw user.invalidate('local.password', c.E.REQUIRED)
}
if (!user.verifyPasswordSync(values['local.password'])) {
throw user.invalidate('local.password', c.E.NOT_VALID)
}
values['local.password'] = req.body.passNew
}
return user.update(values)
})
.then(() => {
return res.json(true)
})
.catch(next)
}
module.exports = mongoose.model('User', userSchema)
Also, we need bcrypt package for password hashing and flat for flattern objects, so let’s add it:1
yarn add bcrypt flat
There’s some nifty functions for make sure password doesn’t store in database, hash only. And little bit functions for profile and avatars.
Timestamps are turned on and virtuals are included in toJSON and toObject conversion.
We’re return error codes instead of messages.
Pros:
- No need to know user locale;
- No need to repeat error message in different places.
Cons: - Error message should be passed as array because of an additional information like numbers (min, max), templates, etc.
Let’s createconstants.jsin theserver/enginedirectory:1
2
3
4
5
6
7
8
9
10
11module.exports = {
'E': {
'REQUIRED': 'E_REQUIRED',
'UNIQUE': 'E_UNIQUE',
'NOT_VALID': 'E_NOT_VALID',
'NOT_MATCH': 'E_NOT_MATCH',
'MIN_LENGTH': 'E_MIN_LENGTH',
'MAX_LENGTH': 'E_MAX_LENGTH',
'NOT_IN_RANGE': 'E_NOT_IN_RANGE'
}
}
Transforming password into hash should be called each time user saved or updated.
Also, we have to be sure that only filled keys should be picked on user update. To set nested objects like local.password we have to make object flat. If we don’t - local key will be completely replaced with a new one.
The last thing is to check password if we want to set new one.
Route
Before handling routes let’s make one more thing: custom response behaviour.
Why do we need that?
- Send errors in response in easy way;
- Custom error messages, extra error data, predefined status codes, etc.
server/engine/response.js:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75const _ = require('lodash')
module.exports = {
BAD_REQUEST: {
status: 400,
type: 'BAD_REQUEST'
},
UNAUTHORIZED: {
status: 401,
type: 'UNAUTHORIZED'
},
REQUEST_FAILED: {
status: 402,
type: 'REQUEST_FAILED'
},
FORBIDDEN: {
status: 403,
type: 'FORBIDDEN'
},
NOT_FOUND: {
status: 404,
type: 'NOT_FOUND'
},
TOO_MANY_REQUEST: {
status: 429,
type: 'TOO_MANY_REQUEST'
},
SERVER_ERROR: {
status: 500,
type: 'SERVER_ERROR'
},
NOT_IMPLEMENTED: {
status: 501,
type: 'NOT_IMPLEMENTED'
},
/**
* Generate a JSON REST API response
*
* If data present and no error, we will send status 200 with JSON data
* If no data but has error, we will send HTTP error code and message
*
* @param {Object} res ExpressJS res object
* @param {Object} data Response data
* @param {Object} err Error object
* @param {String} errMessage Custom error message
* @param {Object} extraParams Extra error params
* @return {*} If res assigned, return with res, otherwise return the response JSON object
*/
json (res, data, err, errMessage, extraParams) {
const response = {}
if (err) {
response.error = err
response.status = err.status || 500
if (errMessage) {
response.error.message = errMessage.message || errMessage
}
if (extraParams && _.isObject(extraParams)) response.error = _.assign(response.error, extraParams)
response.data = data
if (res) res.status(response.status)
} else {
response.status = 200
response.data = data
}
return res ? res.json(response) : response
}
}
Now we ready to make our routing system. Let’s make index.js and user.js files in server/routes directory. index file will gather all routes. user and other ones have to return express-router instance for smooth implementation.
Let’s take a look in user.js:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44const response = require('@engine/response')
const User = require('@models/user')
const _ = require('lodash')
const flatten = require('flat')
const router = require('express').Router()
router.get('/', (req, res, next) => {
User.find({}, {'local.password': 0})
.then(users => {
return res.json(users)
})
.catch(next)
})
router.get('/:id', (req, res, next) => {
User.findById(req.params.id, {'local.password': 0})
.then(user => {
return res.json(user)
})
.catch(next)
})
router.post('/', (req, res, next) => {
let values = _.pick(req.body, _.keys(User.schema.paths))
if (values._id) delete values._id
values = _.pickBy(flatten(values), _.identity)
const newUser = new User(values)
return newUser.save().then(user => {
return response.json(res, _.omit(user.toObject(), 'local.password'))
}).catch(next)
})
router.patch('/:id', User.updateUser.bind(User))
router.delete('/:id', (req, res, next) => {
User.findByIdAndRemove(req.params.id)
.then(() => {
return res.json(true)
})
.catch(next)
})
module.exports = router
Just a REST api, nothing else.
Now index.js:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43const c = require('@engine/constants')
const logger = require('@engine/logger')
const response = require('@engine/response')
const mongoose = require('mongoose')
const _ = require('lodash')
const router = require('express').Router()
module.exports = (app, ...middlewares) => {
router.use('/users', require('./user'))
app.use('/api', router)
// [1]
middlewares.forEach(middleware => middleware(app))
app.use((err, req, res, next) => {
if (!err) return next()
logger.error(err.stack)
// [2]
if (err instanceof mongoose.Error.ValidationError) {
const fields = {}
_.each(err.errors, e => {
fields[e.path] = _.isArray(e.properties) ? e.properties : e.message
})
return response.json(res, null, response.BAD_REQUEST, null, {fields})
} else if (err.name && err.name === 'BulkWriteError') {
switch (err.code) {
case 11000:
let field = err.message.split('.$')[1]
field = field.split(' dup key')[0]
field = field.substring(0, field.lastIndexOf('_'))
return response.json(res, null, response.BAD_REQUEST, null, {fields: {[field]: c.E.UNIQUE}})
}
} else {
return response.json(res, null, response.SERVER_ERROR, err.message)
}
})
app.use((req, res) => {
return response.json(res, null, response.NOT_FOUND)
})
}
There’s some unclear code. The function accepts app argument and ...middlewares. The last one contains middlewares which should be init after routes defined, but before error hadling. In our case it’s Nuxt. We can’t define it before routes, because we won’t reach them (Nuxt middleware won’t call next function) and we can’t define it after error handling, because 404 error will be returned before Nuxt.
In server/engine/express.js we should call routes initialization:1
2
3
4
5
6
7
8
9
10
11module.exports = db => {
const app = express()
initMiddleware(app)
initHelmetHeaders(app)
initSession(app, db)
require('@routes')(app, initNuxt)
return app
}
We’re also have custom check for ValidationError to retrieve fields which was invalid with error messages (or arrays with message and arguments) and BulkWriteError for unique validation (it’s a bit different than Validation one).
This part wraps up 05-user git branch.