Skip to content

Commit e7be300

Browse files
committed
v2: Add unique username validation!
1 parent 820f5ae commit e7be300

File tree

7 files changed

+239
-27
lines changed

7 files changed

+239
-27
lines changed

models/errors.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
var util = require('util');
2+
3+
function ValidationError(msg) {
4+
Error.call(this);
5+
this.name = 'ValidationError';
6+
this.message = msg;
7+
Error.captureStackTrace(this, this.constructor);
8+
}
9+
10+
util.inherits(ValidationError, Error);
11+
12+
exports.ValidationError = ValidationError;

models/user.js

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// User model logic.
33

44
var neo4j = require('neo4j');
5+
var errors = require('./errors');
56

67
var db = new neo4j.GraphDatabase({
78
// Support specifying database info via environment variables,
@@ -19,6 +20,18 @@ var User = module.exports = function User(_node) {
1920
this._node = _node;
2021
}
2122

23+
// Public constants:
24+
25+
User.VALIDATION_INFO = {
26+
'username': {
27+
required: true,
28+
minLength: 2,
29+
maxLength: 16,
30+
pattern: /^[A-Za-z0-9_]+$/,
31+
message: '2-16 characters; letters, numbers, and underscores only.'
32+
},
33+
};
34+
2235
// Public instance properties:
2336

2437
// The user's username, e.g. 'aseemk'.
@@ -28,23 +41,67 @@ Object.defineProperty(User.prototype, 'username', {
2841

2942
// Private helpers:
3043

31-
// Takes the given caller-provided properties (which corresponding to our public
32-
// instance properties), selects only whitelisted ones for editing, validates
33-
// them, and translates them to the corresponding internal db properties.
34-
function translate(props) {
35-
// Today, the only property we have is `username`.
36-
// TODO: Validate it. E.g. length, acceptable chars, etc.
37-
return {
38-
username: props.username,
39-
};
44+
// Takes the given caller-provided properties, selects only known ones,
45+
// validates them, and returns the known subset.
46+
// By default, only validates properties that are present.
47+
// (This allows `User.prototype.patch` to not require any.)
48+
// You can pass `true` for `required` to validate that all required properties
49+
// are present too. (Useful for `User.create`.)
50+
function validate(props, required) {
51+
var safeProps = {};
52+
53+
for (var prop in User.VALIDATION_INFO) {
54+
var val = props[prop];
55+
validateProp(prop, val, required);
56+
safeProps[prop] = val;
57+
}
58+
59+
return safeProps;
60+
}
61+
62+
// Validates the given property based on the validation info above.
63+
// By default, ignores null/undefined/empty values, but you can pass `true` for
64+
// the `required` param to enforce that any required properties are present.
65+
function validateProp(prop, val, required) {
66+
var info = User.VALIDATION_INFO[prop];
67+
var message = info.message;
68+
69+
if (!val) {
70+
if (info.required && required) {
71+
throw new errors.ValidationError(
72+
'Missing ' + prop + ' (required).');
73+
} else {
74+
return;
75+
}
76+
}
77+
78+
if (info.minLength && val.length < info.minLength) {
79+
throw new errors.ValidationError(
80+
'Invalid ' + prop + ' (too short). Requirements: ' + message);
81+
}
82+
83+
if (info.maxLength && val.length > info.maxLength) {
84+
throw new errors.ValidationError(
85+
'Invalid ' + prop + ' (too long). Requirements: ' + message);
86+
}
87+
88+
if (info.pattern && !info.pattern.test(val)) {
89+
throw new errors.ValidationError(
90+
'Invalid ' + prop + ' (format). Requirements: ' + message);
91+
}
92+
}
93+
94+
function isConstraintViolation(err) {
95+
return err instanceof neo4j.ClientError &&
96+
err.neo4j.code === 'Neo.ClientError.Schema.ConstraintViolation';
4097
}
4198

4299
// Public instance methods:
43100

44101
// Atomically updates this user, both locally and remotely in the db, with the
45102
// given property updates.
46103
User.prototype.patch = function (props, callback) {
47-
var safeProps = translate(props);
104+
var safeProps = validate(props);
48105

49106
var query = [
50107
'MATCH (user:User {username: {username}})',
@@ -63,6 +120,15 @@ User.prototype.patch = function (props, callback) {
63120
query: query,
64121
params: params,
65122
}, function (err, results) {
123+
if (isConstraintViolation(err)) {
124+
// TODO: This assumes username is the only relevant constraint.
125+
// We could parse the constraint property out of the error message,
126+
// but it'd be nicer if Neo4j returned this data semantically.
127+
// Alternately, we could tweak our query to explicitly check first
128+
// whether the username is taken or not.
129+
err = new errors.ValidationError(
130+
'The username ‘' + props.username + '’ is taken.');
131+
}
66132
if (err) return callback(err);
67133

68134
if (!results.length) {
@@ -234,13 +300,22 @@ User.create = function (props, callback) {
234300
].join('\n');
235301

236302
var params = {
237-
props: translate(props)
303+
props: validate(props)
238304
};
239305

240306
db.cypher({
241307
query: query,
242308
params: params,
243309
}, function (err, results) {
310+
if (isConstraintViolation(err)) {
311+
// TODO: This assumes username is the only relevant constraint.
312+
// We could parse the constraint property out of the error message,
313+
// but it'd be nicer if Neo4j returned this data semantically.
314+
// Alternately, we could tweak our query to explicitly check first
315+
// whether the username is taken or not.
316+
err = new errors.ValidationError(
317+
'The username ‘' + props.username + '’ is taken.');
318+
}
244319
if (err) return callback(err);
245320
var user = new User(results[0]['user']);
246321
callback(null, user);

public/stylesheets/style.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ strong a {
99
padding: 0 0.25em;
1010
background-color: #ffa;
1111
}
12+
.error {
13+
color: #800;
14+
}

routes/users.js

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,56 @@
11
// users.js
22
// Routes to CRUD users.
33

4+
var URL = require('url');
5+
6+
var errors = require('../models/errors');
47
var User = require('../models/user');
58

9+
function getUserURL(user) {
10+
return '/users/' + encodeURIComponent(user.username);
11+
}
12+
613
/**
714
* GET /users
815
*/
916
exports.list = function (req, res, next) {
1017
User.getAll(function (err, users) {
1118
if (err) return next(err);
1219
res.render('users', {
13-
users: users
20+
User: User,
21+
users: users,
22+
username: req.query.username, // Support pre-filling create form
23+
error: req.query.error, // Errors creating; see create route
1424
});
1525
});
1626
};
1727

1828
/**
19-
* POST /users
29+
* POST /users {username, ...}
2030
*/
2131
exports.create = function (req, res, next) {
2232
User.create({
23-
username: req.body['username']
33+
username: req.body.username
2434
}, function (err, user) {
25-
if (err) return next(err);
26-
res.redirect('/users/' + user.username);
35+
if (err) {
36+
if (err instanceof errors.ValidationError) {
37+
// Return to the create form and show the error message.
38+
// TODO: Assuming username is the issue; hardcoding for that
39+
// being the only input right now.
40+
// TODO: It'd be better to use a cookie to "remember" this info,
41+
// e.g. using a flash session.
42+
return res.redirect(URL.format({
43+
pathname: '/users',
44+
query: {
45+
username: req.body.username,
46+
error: err.message,
47+
},
48+
}));
49+
} else {
50+
return next(err);
51+
}
52+
}
53+
res.redirect(getUserURL(user));
2754
});
2855
};
2956

@@ -32,28 +59,50 @@ exports.create = function (req, res, next) {
3259
*/
3360
exports.show = function (req, res, next) {
3461
User.get(req.params.username, function (err, user) {
62+
// TODO: Gracefully "no such user" error. E.g. 404 page.
3563
if (err) return next(err);
3664
// TODO: Also fetch and show followers? (Not just follow*ing*.)
3765
user.getFollowingAndOthers(function (err, following, others) {
3866
if (err) return next(err);
3967
res.render('user', {
68+
User: User,
4069
user: user,
4170
following: following,
42-
others: others
71+
others: others,
72+
username: req.query.username, // Support pre-filling edit form
73+
error: req.query.error, // Errors editing; see edit route
4374
});
4475
});
4576
});
4677
};
4778

4879
/**
49-
* POST /users/:username
80+
* POST /users/:username {username, ...}
5081
*/
5182
exports.edit = function (req, res, next) {
5283
User.get(req.params.username, function (err, user) {
84+
// TODO: Gracefully "no such user" error. E.g. 404 page.
5385
if (err) return next(err);
5486
user.patch(req.body, function (err) {
55-
if (err) return next(err);
56-
res.redirect('/users/' + user.username);
87+
if (err) {
88+
if (err instanceof errors.ValidationError) {
89+
// Return to the edit form and show the error message.
90+
// TODO: Assuming username is the issue; hardcoding for that
91+
// being the only input right now.
92+
// TODO: It'd be better to use a cookie to "remember" this
93+
// info, e.g. using a flash session.
94+
return res.redirect(URL.format({
95+
pathname: getUserURL(user),
96+
query: {
97+
username: req.body.username,
98+
error: err.message,
99+
},
100+
}));
101+
} else {
102+
return next(err);
103+
}
104+
}
105+
res.redirect(getUserURL(user));
57106
});
58107
});
59108
};
@@ -63,6 +112,8 @@ exports.edit = function (req, res, next) {
63112
*/
64113
exports.del = function (req, res, next) {
65114
User.get(req.params.username, function (err, user) {
115+
// TODO: Gracefully handle "no such user" error somehow.
116+
// E.g. redirect back to /users with an info message?
66117
if (err) return next(err);
67118
user.del(function (err) {
68119
if (err) return next(err);
@@ -72,32 +123,42 @@ exports.del = function (req, res, next) {
72123
};
73124

74125
/**
75-
* POST /users/:username/follow
126+
* POST /users/:username/follow {otherUsername}
76127
*/
77128
exports.follow = function (req, res, next) {
78129
User.get(req.params.username, function (err, user) {
130+
// TODO: Gracefully handle "no such user" error somehow.
131+
// This is the source user, so e.g. 404 page?
79132
if (err) return next(err);
80133
User.get(req.body.otherUsername, function (err, other) {
134+
// TODO: Gracefully handle "no such user" error somehow.
135+
// This is the target user, so redirect back to the source user w/
136+
// an info message?
81137
if (err) return next(err);
82138
user.follow(other, function (err) {
83139
if (err) return next(err);
84-
res.redirect('/users/' + user.username);
140+
res.redirect(getUserURL(user));
85141
});
86142
});
87143
});
88144
};
89145

90146
/**
91-
* POST /users/:username/unfollow
147+
* POST /users/:username/unfollow {otherUsername}
92148
*/
93149
exports.unfollow = function (req, res, next) {
94150
User.get(req.params.username, function (err, user) {
151+
// TODO: Gracefully handle "no such user" error somehow.
152+
// This is the source user, so e.g. 404 page?
95153
if (err) return next(err);
96154
User.get(req.body.otherUsername, function (err, other) {
155+
// TODO: Gracefully handle "no such user" error somehow.
156+
// This is the target user, so redirect back to the source user w/
157+
// an info message?
97158
if (err) return next(err);
98159
user.unfollow(other, function (err) {
99160
if (err) return next(err);
100-
res.redirect('/users/' + user.username);
161+
res.redirect(getUserURL(user));
101162
});
102163
});
103164
});

0 commit comments

Comments
 (0)