A => .gitignore +3 -0
@@ 1,3 @@
+node_modules/*
+**/node_modules/*
+lib/**/db
A => .npmrc +1 -0
@@ 1,1 @@
+package-lock=false
A => README.md +96 -0
@@ 1,96 @@
+# user-profiles-crud-api
+
+A minimal demonstration of a CRUD API for user profiles
+
+For the sake of minimising the technical scope and time spent on this
+MVP, various featurs are not implemented, preventing this from being
+considered remotely close to production-ready. These are listed below,
+under the _TODO_ heading.
+
+## Install
+
+After cloning the repository locally, simply install the sparse number
+of dependencies for the project via _npm_, `npm install`.
+
+## Usage
+
+In your local directory containing the cloned project, you may run
+`npm start` as you usually would to run the project using _npm_
+directly. However, do note that the application currently uses an
+environment variable, `JWT_SECRET`, to assign an HMAC secret for the
+purpose of the JSON Web Token functionality. You can simply set this at
+run time via `JWT_SECRET=somesecret npm start`.
+
+Once running, you may connect to the service in whichever method you
+prefer. As I prefer using `curl`, let's use that for the example:
+
+```bash
+curl -i -X POST \
+ -d '{"username":"foo","name":"Foo McBar","email":"foo@bar.com","password":"Password123!"}' \
+ -H "Content-Type: application/json" \
+ http://localhost:8000/auth/register
+```
+
+Which returns a corresponding JSON response, such as:
+
+```
+{"id":"fZcp8B4l6xYjiJnwfbQNi","username":"foo","email":"foo@bar.com","name":"Foo McBar"}
+```
+
+You may now log into the auth controller to retrieve your JWT token:
+
+```bash
+curl -i -X POST \
+ -d '{"username":"foo","password":"Pasword123!"}' \
+ -H "Content-Type: application/json" \
+ http://localhost:8000/auth/login
+```
+
+Which returns a corresponding JSON response, such as:
+
+```
+{"username":"foo","email":"foo@bar.com","name":"Foo McBar","accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im4zeW9XU3o2VUNEdlpUN21USFNFayIsImlhdCI6MTY0MDMzNTk5NSwiZXhwIjoxNjQwNDIyMzk1fQ.N6Yks1ZvJxy7qIV_W1xAwCKLDcPrahW7aYeLwA3lJ2o"}
+```
+
+This token is set to expire 24 hours after time it was issued. Using
+this token, you may now pass the authentication necessary to view user
+profiles, as well as updating or deleting your own profile.
+
+```bash
+curl -i \
+ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJLbk94NHd6bnFZbzFzLW5PSEp5cyIsImlhdCI6MTY0MDMzNjc0MSwiZXhwIjoxNjQwNDIzMTQxfQ.Fj-5n5ABSYFfRxwc_ti7TkvHgmSIO0_PaSrs38e_vlQ" \
+ http://localhost:8000/users
+```
+
+Returning:
+
+```
+[{"key":"2KnOx4wznqYo1s-nOHJys","value":"{\"username\":\"foo\",\"hashedPassword\":\"$2b$10$QCE2DWZfwiYhzmvdX7BR.OQpfJUhuSGTjzMJV9kt148z/mnzrIcd6\",\"email\":\"foo@bar.com\",\"name\":\"Foo McBar\"}"}]
+```
+
+## Endpoints
+
+## Tests
+
+To run the test suite, run via _npm_, `npm t`.
+
+## TODO and other missing features
+
+- implement unique constraints to user profile data (ie. username,
+ email)
+- hardening of any sort, such as CSRF, CORS, DDoS prevention, etc.
+- performance enhancement of any sort, such as caching, code
+ minimisation, etc.
+- robust error-handling
+- special header tags
+- a `dockerfile` for easy containerisation
+- thorough tests including:
+ - controller responses, incoming request properties and data types,
+ duplicate records, etc.
+ - status codes
+ - validate request and response streams and properties in routes files
+- better developer documentation
+- robust jwt implementation
+- configuration of process environment variables
+- code coverage (`nyc`, for example)
+- jwt token invalidation
A => bin/postinstall +12 -0
@@ 1,12 @@
+#!/usr/bin/env node
+
+const { F_OK } = require('fs').constants
+const { access, symlink } = require('fs').promises
+const { resolve } = require('path')
+const lib = resolve(__dirname, '../lib/')
+const symlinkPath = resolve(__dirname, '../node_modules/_')
+
+access(symlinkPath, F_OK)
+ .catch(err => symlink(lib, symlinkPath))
+ .then(() => process.stdout.write('> ./lib/ successfully symlinked as ./node_modules/_\n'))
+
A => bin/preinstall +20 -0
@@ 1,20 @@
+#!/usr/bin/env node
+
+const { F_OK, R_OK } = require('fs').constants
+const { access, readdir } = require('fs').promises
+const { resolve, join } = require('path')
+const cp = require('child_process')
+
+function installDependency (cwd) {
+ return Promise.resolve()
+ .then(() => access(join(cwd, 'package.json'), F_OK | R_OK))
+ .then(() =>
+ cp.spawn('npm', ['i'], { env: process.env, cwd, stdio: 'inherit' })
+ )
+ .catch(() => {})
+}
+
+const lib = resolve(__dirname, '../lib/')
+
+readdir(lib)
+ .then(mods => Promise.all(mods.map(m => installDependency(join(lib, m)))))
A => bin/seed.js +6 -0
@@ 1,6 @@
+const userRepository = require('../lib/users/repository')
+
+// userRepository.batch()
+// // .put('2021-12-18', { remaining: 350 })
+// // .put('2021-12-19', { remaining: 350 })
+// .write(() => console.log('Seeding users complete!'))
A => index.js +17 -0
@@ 1,17 @@
+process.title = require('./package.json').name
+
+const argv = require('minimist')(process.argv.slice(2), {
+ alias: { p: 'port' },
+ default: { p: 8000 },
+})
+
+const routes = [
+ require('_/auth/routes').bind(null, require('_/auth/controller')),
+ require('_/users/routes').bind(null, require('_/users/controller')),
+]
+
+const server = require('_/server')(routes)
+
+server.listen(process.env.PORT || argv.port, () =>
+ console.log(`Server running at http://localhost:${server.address().port}`)
+)
A => lib/auth/controller.js +110 -0
@@ 1,110 @@
+const SECRET = process.env.JWT_SECRET
+const jwt = require('jsonwebtoken')
+const User = require('_/users/model')
+const service = require('./service')
+const UserRepository = require('_/users/repository')
+const { JWT_SECRET } = process.env
+
+module.exports = (req, res) => {
+ if (req === undefined || !(req instanceof Object))
+ throw new Error('request object is required')
+ if (res === undefined || !(res instanceof Object))
+ throw new Error('response object is required')
+
+ function register() {
+ return new Promise((resolve, reject) => {
+ let user = req?.body
+ return User(user)
+ .then(res => user = res && res)
+ .then(user => UserRepository.create(user))
+ .then(id => user = { id, ...User.serialise(user) })
+ .then(() => handleResponse(201, user))
+ .then(() => resolve(user))
+ .catch(e => reject(e))
+ })
+ }
+
+ function login() {
+ return new Promise((resolve, reject) => {
+ const { username, password } = req.body
+ let loggedInUser
+ return service.login({ username, password })
+ .then(user => {
+ loggedInUser = user
+ return jwt.sign({ id: user.id }, JWT_SECRET, {
+ expiresIn: 86400 // 24 hours
+ })
+ })
+ .then(accessToken => {
+ req.userId = loggedInUser.id
+ return accessToken
+ })
+ .then(accessToken => handleResponse(200, {
+ ...User.serialise(loggedInUser),
+ accessToken })
+ )
+ .then(resolve)
+ .catch(reject)
+ })
+ }
+
+ function verifyToken (delegate) {
+ return new Promise((resolve, reject) => {
+ if (delegate === undefined || typeof delegate !== 'function')
+ return reject({
+ status: 500,
+ message: new Error('delegate function is required')
+ })
+ const [type, token] = req?.headers['authorization']?.split(' ') || []
+ if (type !== 'Bearer' || !token)
+ return reject({
+ status: 403,
+ message: new Error('authorization token is required')
+ })
+ jwt.verify(token, JWT_SECRET, (err, decoded) => {
+ if (err)
+ return reject({ status: 401, message: new Error('unauthorized') })
+ req.userId = decoded.id
+ return delegate()
+ .then(resolve)
+ .catch(reject)
+ })
+ })
+ }
+
+ function handleResponse(status, message) {
+ if (status !== parseInt(status, 10))
+ throw new Error(status + ' handleResponse status must be a valid number')
+ if (message instanceof Error)
+ message = message.message
+ message = typeof message === 'string'
+ ? message
+ : JSON.stringify(message)
+ return res.writeHead(status, {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(Buffer.from(message)),
+ })
+ .end(message)
+ }
+
+ function asyncErrorBoundary(delegate, defaultStatus = 500) {
+ if (delegate === undefined || typeof delegate !== 'function')
+ return Promise.reject({
+ status: 500,
+ message: new Error('delegate function is required')
+ })
+ return new Promise((resolve, reject) => delegate()
+ .then(resolve)
+ .catch((error = {}) => {
+ const { status = defaultStatus, message = error } = error
+ return reject(handleResponse(status, message))
+ })
+ )
+ }
+
+ return {
+ register: () => asyncErrorBoundary(register),
+ login: () => asyncErrorBoundary(login),
+ verifyToken,
+ }
+}
A => lib/auth/controller.test.js +187 -0
@@ 1,187 @@
+process.env.NODE_ENV = 'test'
+process.env.JWT_SECRET = 'secret'
+const test = require('tape')
+const { controller } = require('.')
+
+const req = (props = {}) => ({ url: '', headers: {}, ...props })
+
+const res = () => ({
+ writeHead(status, opts) {
+ this.statusCode = status
+ return this
+ },
+ statusCode: 200,
+ end: msg => msg,
+})
+
+test('Auth controller', function (t) {
+ t.test('es a factory function', function (st) {
+ st.equal(typeof controller, 'function')
+ st.end()
+ })
+
+ t.test('requires a request and response argument', function (st) {
+ st.throws(() => controller(), /required/)
+ st.end()
+ })
+
+ t.test('returns an object', function (st) {
+ st.doesNotThrow(() => controller({}, {}), /required/)
+ st.end()
+ })
+
+ t.test('has static methods', function (st) {
+ const ctrlr = controller(req(), res())
+ st.equal(Object.keys(ctrlr).length, 3)
+ st.ok(ctrlr.hasOwnProperty('register'))
+ st.ok(ctrlr.hasOwnProperty('login'))
+ st.ok(ctrlr.hasOwnProperty('verifyToken'))
+ st.end()
+ })
+
+ t.test('#register()', function (st) {
+ st.test('requires a valid username', st => {
+ st.plan(1)
+ const ctrlr = controller(req({
+ body: {},
+ }), res())
+ ctrlr.register()
+ .catch(e => st.ok(JSON.parse(e).username.some(e => /required/.test(e))))
+ })
+
+ st.test('requires a valid password', st => {
+ st.plan(1)
+ const ctrlr = controller(req({
+ body: {},
+ }), res())
+ ctrlr.register()
+ .catch(e => st.ok(JSON.parse(e).password.some(e => /required/.test(e))))
+ })
+
+ st.test('returns a user object', st => {
+ st.plan(3)
+ const ctrlr = controller(
+ req({
+ body: {
+ username: 'foo',
+ name: 'Foo Bar',
+ password: 'Password123!',
+ email: 'foo@bar.com',
+ }
+ }),
+ res()
+ )
+ ctrlr.register()
+ .then(user => {
+ st.equal(typeof user, 'object')
+ st.ok(user.hasOwnProperty('id'))
+ st.ok(!user.hasOwnProperty('password'))
+ })
+ })
+ })
+
+ t.test('#login()', function (st) {
+ st.test('requires a valid username', st => {
+ st.plan(1)
+ const ctrlr = controller(req({ body: {} }), res())
+ ctrlr.login()
+ .catch(e => st.ok(e => /required/.test(e)))
+ })
+
+ st.test('requires a valid password', st => {
+ st.plan(1)
+ const ctrlr = controller(req({ body: { username: 'foo' } }), res())
+ ctrlr.login()
+ .catch(e => console.log('SHIT HERE', e) || st.ok(e => /required/.test(e)))
+ })
+
+ st.test('returns a serialised user object with token as JSON', st => {
+ st.plan(5)
+ const newUser = {
+ username: 'foo',
+ name: 'Foo Bar',
+ password: 'Password123!',
+ email: 'foo@bar.com',
+ }
+ const ctrlr = controller(req({ body: newUser }), res())
+ ctrlr.register()
+ .then(user => {
+ const { username, password } = newUser
+ return controller(req({ body: { username, password }}), res())
+ .login()
+ })
+ .then(user => {
+ st.ok(typeof user === 'string')
+ st.doesNotThrow(() => JSON.parse(user))
+ st.ok(typeof JSON.parse(user) === 'object')
+ st.ok(JSON.parse(user).hasOwnProperty('accessToken'))
+ st.ok(!JSON.parse(user).hasOwnProperty('hashedPassword'))
+ })
+ })
+ })
+
+ t.test('#verifyToken()', function (st) {
+ st.test('requires a delegate function', st => {
+ st.plan(2)
+ const response = res()
+ controller(req(), response).verifyToken()
+ .catch(e => {
+ st.ok(/delegate.+function/i.test(e.message))
+ st.equal(e.status, 500)
+ })
+ })
+
+ st.test('requires an "Authorization" header', st => {
+ st.plan(2)
+ const response = res()
+ const ctrlr = controller(req(), response)
+ ctrlr.verifyToken(() => {})
+ .catch(e => {
+ st.ok(/authorization token/i.test(e.message))
+ st.equal(e.status, 403)
+ })
+ })
+
+ st.test('requires a valid access token', st => {
+ st.plan(2)
+ const response = res()
+ const ctrlr = controller(req({
+ headers: { 'Authorization': 'Bearer 123' },
+ }), response)
+ ctrlr.verifyToken(() => {})
+ .catch(e => {
+ st.ok(/unauthorized/i.test(e.message))
+ st.equal(e.status, 401)
+ })
+ })
+
+ st.test('adds authorized user ID to the request and calls the delegate', st => {
+ st.plan(2)
+ const newUser = {
+ username: 'foo',
+ name: 'Foo Bar',
+ password: 'Password123!',
+ email: 'foo@bar.com',
+ }
+ controller(req({ body: newUser }), res()).register()
+ .then(user => {
+ const { username, password } = newUser
+ return controller(req({ body: { username, password }}), res()).login()
+ })
+ .then(user => controller(req({ body: newUser }), res()).login())
+ .then(user => {
+ const request = req({
+ headers: {
+ 'Authorization': 'Bearer ' + JSON.parse(user).accessToken
+ },
+ })
+ return controller(request, res())
+ .verifyToken(() => Promise.resolve([true, request]))
+ })
+ .then(([response, request]) => {
+ st.ok(response)
+ st.ok(request.hasOwnProperty('userId'))
+ })
+ })
+ })
+})
A => lib/auth/index.js +9 -0
@@ 1,9 @@
+const controller = require('./controller')
+const service = require('./service')
+const routes = require('./routes')
+
+module.exports = {
+ controller,
+ service,
+ routes,
+}
A => lib/auth/index.test.js +17 -0
@@ 1,17 @@
+const test = require('tape')
+const feature = require('.')
+
+test('Auth feature', function (t) {
+ t.test('returns an object', function (st) {
+ st.equal(typeof feature, 'object')
+ st.end()
+ })
+
+ t.test('has static methods', function (st) {
+ st.equal(Object.keys(feature).length, 3)
+ st.ok(feature.hasOwnProperty('controller'))
+ st.ok(feature.hasOwnProperty('service'))
+ st.ok(feature.hasOwnProperty('routes'))
+ st.end()
+ })
+})
A => lib/auth/package.json +14 -0
@@ 1,14 @@
+{
+ "name": "auth",
+ "version": "1.0.0",
+ "description": "Authentication feature domain",
+ "main": "index.js",
+ "private": true,
+ "dependencies": {
+ "jsonwebtoken": "^8.5.1"
+ },
+ "devDependencies": {
+ "level-mem": "^6.0.1",
+ "tape": "^5.3.2"
+ }
+}
A => lib/auth/routes.js +12 -0
@@ 1,12 @@
+module.exports = (controller, req, res) => {
+ if (controller === undefined || !(controller instanceof Object))
+ throw new Error('controller is required')
+
+ controller = controller(req, res)
+
+ if (/^\/auth\/login\/?$/.test(req.url) && req.method === 'POST')
+ return controller.login()
+
+ if (/^\/auth\/register\/?$/.test(req.url) && req.method === 'POST')
+ return controller.register()
+}
A => lib/auth/routes.test.js +31 -0
@@ 1,31 @@
+const test = require('tape')
+const { routes } = require('.')
+
+const controller = (req, res) => ({
+ register: () => true,
+ login: () => true,
+})
+
+test('Auth routes', function (t) {
+ t.test('is a function', function (st) {
+ st.equal(typeof routes, 'function')
+ st.end()
+ })
+
+ t.test('requires a controller', function (st) {
+ st.throws(() => routes(), /controller.+required/i)
+ st.end()
+ })
+
+ t.test('matches POST /auth/login(/)', function (st) {
+ st.ok(routes(controller, { method: 'POST', url: '/auth/login' }, {}))
+ st.ok(routes(controller, { method: 'POST', url: '/auth/login/' }, {}))
+ st.end()
+ })
+
+ t.test('matches POST /auth/register(/)', function (st) {
+ st.ok(routes(controller, { method: 'POST', url: '/auth/register' }, {}))
+ st.ok(routes(controller, { method: 'POST', url: '/auth/register/' }, {}))
+ st.end()
+ })
+})
A => lib/auth/service.js +24 -0
@@ 1,24 @@
+const repository = require('_/users/repository')
+const User = require('_/users/model')
+
+module.exports = {
+ login: ({ username, password } = {}) =>
+ new Promise((resolve, reject) => {
+ if (username === undefined || typeof username !== 'string')
+ return reject('username is required')
+ if (password === undefined || typeof password !== 'string')
+ return reject('password is required')
+ let loggedInUser
+ return repository.findBy('username', username)
+ .then(user => loggedInUser = user && user ? user : reject('username not found'))
+ .then(user => User.comparePassword(
+ password,
+ user.hashedPassword || ''
+ ))
+ .then(match => match
+ ? resolve(loggedInUser)
+ : reject('username and password do not match')
+ )
+ .catch(reject)
+ }),
+}
A => lib/auth/service.test.js +57 -0
@@ 1,57 @@
+process.env.NODE_ENV = 'test'
+const test = require('tape')
+const { service } = require('.')
+const { repository } = require('_/users')
+
+test('Auth service', function (t) {
+ t.test('returns an object', function (st) {
+ st.equal(typeof service, 'object')
+ st.end()
+ })
+
+ t.test('has static methods', function (st) {
+ st.equal(Object.keys(service).length, 1)
+ st.ok(service.hasOwnProperty('login'))
+ st.end()
+ })
+
+ t.test('#login()', function (st) {
+ st.test('requires a username', st => {
+ st.plan(2)
+ service.login()
+ .catch(e => st.ok(/username is required/.test(e)))
+ .then(() => service.login({ username: 'foo' }))
+ .catch(e => st.notOk(/username is required/.test(e)))
+ })
+
+ st.test('requires a password', st => {
+ st.plan(2)
+ service.login({ username: 'foo' })
+ .catch(e => st.ok(/password is required/.test(e)))
+ .then(() => service.login({ username: 'foo', password: 'foo' }))
+ .catch(e => st.notOk(/password is required/.test(e)))
+ })
+
+ st.test('throws an error if the username was not found', st => {
+ st.plan(1)
+ service.login({ username: 'jim-bob', password: 'secretsauce' })
+ .catch(e => st.ok(/username not found/.test(e)))
+ })
+
+ st.test('throws an error if the username and password are invalid', st => {
+ st.plan(1)
+ const user = {
+ username: 'foo',
+ name: 'Foo Bar',
+ password: 'Password123!',
+ email: 'foo@bar.com',
+ }
+ repository.create(user)
+ .then(() => service.login({
+ username: user.username,
+ password: 'fishyfishfish'
+ }))
+ .catch(e => st.ok(/username and password do not match/.test(e)))
+ })
+ })
+})
A => lib/server/index.js +36 -0
@@ 1,36 @@
+const http = require('http')
+const url = require('url')
+const { createReadStream } = require('fs')
+
+module.exports = (routes = []) => http.createServer((req, res) => {
+ req.body = req.body || {}
+ req.queryParams = new URLSearchParams(req.url.split('?')[1])
+ req.url = url.parse(req.url).pathname
+
+ req.on('data', chunk => {
+ try {
+ const { 'content-type': contentType } = req.headers
+ if (/application\/json/.test(contentType)) {
+ body = JSON.parse(chunk.toString())
+ req.body = Object.assign({}, req.body, body)
+ }
+ }
+ catch (e) {
+ const { message } = e
+ return res.writeHead(500, {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(Buffer.from(message)),
+ }).end(JSON.stringify(message))
+ }
+ })
+
+ req.on('end', async () => {
+ ;[...routes, function (req, res) {
+ const message = 'not found'
+ return res.writeHead(404, {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(Buffer.from(message)),
+ }).end(message)
+ }].some(route => res.writableEnded || route(req, res))
+ })
+})
A => lib/server/index.test.js +22 -0
@@ 1,22 @@
+const test = require('tape')
+const server = require('.')
+const http = require('http')
+
+test('server', function (t) {
+ t.test('returns a function', function (st) {
+ st.equal(typeof server, 'function')
+ st.end()
+ })
+
+ t.test('accepts an array of routes', function (st) {
+ st.equal(typeof server([]), 'object')
+ st.equal(typeof server(), 'object')
+ st.equal(server().__proto__, http.createServer().__proto__)
+ st.end()
+ })
+
+ t.test('exposes a .listen method', function (st) {
+ st.equal(typeof server().listen, 'function')
+ st.end()
+ })
+})
A => lib/users/controller.js +111 -0
@@ 1,111 @@
+const model = require('./model')
+const repository = require('./repository')
+const authController = require('_/auth/controller')
+
+module.exports = (req, res) => {
+ if (req === undefined || !(req instanceof Object))
+ throw new Error('request object is required')
+ if (res === undefined || !(res instanceof Object))
+ throw new Error('response object is required')
+
+ const verifyToken = authController(req, res).verifyToken
+
+ function validId (id) {
+ return /^([0-9a-z_-]){21}\/?$/i.test(id)
+ }
+
+ function getIdUrlParameter(url) {
+ return req.url.replace(/^\/.+\//, '')
+ }
+
+ function getUser() {
+ return new Promise((resolve, reject) => {
+ const id = getIdUrlParameter(req.url)
+ if (!validId(id))
+ return reject(new Error('id parameter is required'))
+ return repository.get(id)
+ .then(user => handleResponse(user) && resolve(user))
+ .catch(reject)
+ })
+ }
+
+ function list() {
+ return repository.list().then(handleResponse)
+ }
+
+ function register() {
+ return new Promise((resolve, reject) => {
+ let user = req?.body
+ if (!user) return reject(new Error('user payload is required'))
+ return model(user)
+ .then(res => user = res && res)
+ .then(user => repository.create(user))
+ .then(id => user = { id, ...model.serialise(user) })
+ .then(() => res.statusCode = 201 && handleResponse(user))
+ .then(() => resolve(user))
+ .catch(e => reject(e))
+ })
+ }
+
+ function remove() {
+ return new Promise((resolve, reject) => {
+ const id = getIdUrlParameter(req.url)
+ if (!validId(id))
+ return reject(new Error('id parameter is required'))
+ if (id !== req.userId) {
+ req.statusCode = 403
+ return reject(new Error('forbidden'))
+ }
+ return repository.remove(id)
+ .then(resolve)
+ .catch(reject)
+ })
+ }
+
+ function put() {
+ return new Promise((resolve, reject) => {
+ const id = getIdUrlParameter(req.url)
+ if (!validId(id))
+ return reject(new Error('id parameter is required'))
+ let user = req?.body
+ if (!user) return reject(new Error('user payload is required'))
+ if (id !== req.userId) {
+ req.statusCode = 403
+ return reject(new Error('forbidden'))
+ }
+ return repository.put(id, user)
+ .then(resolve)
+ .catch(reject)
+ })
+ }
+
+ function handleResponse(message) {
+ if (message instanceof Error)
+ message = message.message
+ message = typeof message === 'string'
+ ? message
+ : JSON.stringify(message)
+ return res.writeHead(res.statusCode || 200, {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(Buffer.from(message)),
+ }).end(message)
+ }
+
+ function asyncErrorBoundary(delegate, defaultStatus = 500) {
+ return new Promise((resolve, reject) => delegate()
+ .then(resolve)
+ .catch((error = {}) => {
+ const { status = defaultStatus, message = error } = error
+ return reject(handleResponse(message))
+ })
+ )
+ }
+
+ return {
+ register: () => asyncErrorBoundary(register),
+ list: () => asyncErrorBoundary(verifyToken.bind(null, list)),
+ getUser: () => asyncErrorBoundary(verifyToken.bind(null, getUser)),
+ remove: () => asyncErrorBoundary(verifyToken.bind(null, remove)),
+ put: () => asyncErrorBoundary(verifyToken.bind(null, put)),
+ }
+}
A => lib/users/controller.test.js +322 -0
@@ 1,322 @@
+process.env.NODE_ENV = 'test'
+const test = require('tape')
+const { controller } = require('.')
+const { controller: authController } = require('_/auth')
+
+const request = (props = {}) => ({
+ url: '',
+ headers: {},
+ ...props
+})
+
+const res = {
+ writeHead(status, opts) {
+ return this
+ },
+ end: msg => msg,
+}
+
+const testUser = {
+ username: 'foo',
+ name: 'Foo Bar',
+ password: 'Password123!',
+ email: 'foo@bar.com',
+}
+
+function authenticateUser(user, req, res) {
+ req.body = user
+ return authController(req, res).register()
+ .then(data => {
+ user.id = data.id
+ req.body = {
+ username: user.username,
+ password: user.password,
+ }
+ return authController(req, res).login()
+ })
+ .then(user => req.headers['Authorization'] =
+ 'Bearer ' + JSON.parse(user).accessToken
+ )
+}
+
+test('User controller', function (t) {
+ t.test('is a factory function', function (st) {
+ st.equal(typeof controller, 'function')
+ st.end()
+ })
+
+ t.test('requires an request and response argument', function (st) {
+ st.throws(() => controller(), /required/)
+ st.end()
+ })
+
+ t.test('returns an object', function (st) {
+ st.doesNotThrow(() => controller({}, {}), /required/)
+ st.end()
+ })
+
+ t.test('has static methods', function (st) {
+ const ctrlr = controller(request(), res)
+ st.equal(Object.keys(ctrlr).length, 5)
+ st.ok(ctrlr.hasOwnProperty('list'))
+ st.ok(ctrlr.hasOwnProperty('getUser'))
+ st.ok(ctrlr.hasOwnProperty('register'))
+ st.ok(ctrlr.hasOwnProperty('remove'))
+ st.ok(ctrlr.hasOwnProperty('put'))
+ st.end()
+ })
+
+ t.test('#getUser()', function (st) {
+ st.test('requires an authorization token', st => {
+ st.plan(1)
+ controller(request(), res).getUser()
+ .catch(e => st.ok(/authorization token/.test(e)))
+ })
+
+ st.test('requires an ID in the requested URL', st => {
+ st.plan(2)
+ const req = request()
+ authenticateUser(testUser, req, res)
+ .then(() => controller(req, res).getUser())
+ .catch(e => st.ok(/id parameter is required/.test(e)))
+ .then(() => controller({ ...req, url: '/users/' }, res).getUser())
+ .catch(e => st.ok(/id parameter is required/.test(e)))
+ })
+
+ st.test('requires a valid ID', st => {
+ st.plan(1)
+ const req = request()
+ authenticateUser(testUser, req, res)
+ .then(() => controller({ ...req, url: '/users/1'}, res).getUser())
+ .catch(e => st.ok(/id parameter is required/.test(e)))
+ })
+
+ st.test('returns a user object', st => {
+ st.plan(2)
+ const req = request()
+ authenticateUser(testUser, req, res)
+ .then(_ => {
+ req.url = '/users/' + req.userId
+ return controller(req, res).getUser()
+ })
+ .then(row => {
+ st.equal(typeof row, 'object')
+ st.equal(row.name, 'Foo Bar')
+ })
+ })
+ })
+
+ t.test('#register()', function (st) {
+ st.test('requires a user payload in the request', st => {
+ st.plan(1)
+ const ctrlr = controller(request(), res)
+ ctrlr.register()
+ .catch(e => st.ok(/user payload is required/.test(e)))
+ })
+
+ st.test('requires a valid username', st => {
+ st.plan(1)
+ const ctrlr = controller(request({
+ url: '/users',
+ body: {},
+ }), res)
+ ctrlr.register()
+ .catch(e => st.ok(JSON.parse(e).username.some(e => /required/.test(e))))
+ })
+
+ st.test('requires a valid password', st => {
+ st.plan(1)
+ const ctrlr = controller(request({
+ body: {},
+ }), res)
+ ctrlr.register()
+ .catch(e => st.ok(JSON.parse(e).password.some(e => /required/.test(e))))
+ })
+
+ st.test('requires a valid email', st => {
+ st.plan(1)
+ const ctrlr = controller(request({
+ url: '/users',
+ body: {},
+ }), res)
+ ctrlr.register()
+ .catch(e => st.ok(JSON.parse(e).email.some(e => /required/.test(e))))
+ })
+
+ st.test('requires a valid name', st => {
+ st.plan(1)
+ const ctrlr = controller(request({
+ url: '/users',
+ body: {},
+ }), res)
+ ctrlr.register()
+ .catch(e => st.ok(JSON.parse(e).name.some(e => /required/.test(e))))
+ })
+
+ st.test('returns a user object', st => {
+ st.plan(3)
+ const ctrlr = controller(
+ request({
+ body: {
+ username: 'foo',
+ name: 'Foo Bar',
+ password: 'Password123!',
+ email: 'foo@bar.com',
+ }
+ }),
+ res
+ )
+ ctrlr.register()
+ .then(user => {
+ st.equal(typeof user, 'object')
+ st.ok(user.hasOwnProperty('id'))
+ st.ok(!user.hasOwnProperty('password'))
+ })
+ })
+ })
+
+ t.test('#list()', function (st) {
+ st.test('returns an array of users', st => {
+ st.plan(2)
+ const req = request()
+ authenticateUser(testUser, req, res)
+ .then(() => controller(req, res).list())
+ .then(users => {
+ users = JSON.parse(users)
+ st.ok(Array.isArray(users))
+ st.equal(typeof users[0], 'object')
+ })
+ })
+ })
+
+ t.test('#remove()', function (st) {
+ st.test('requires an ID in request.url', st => {
+ st.plan(1)
+ const req = request({ url: '/users/' })
+ authenticateUser(testUser, req, res)
+ .then(() => controller(req, res).remove())
+ .catch(e => st.ok(/id parameter is required/.test(e)))
+ })
+
+ st.test('requires a valid ID', st => {
+ st.plan(1)
+ const req = request({ url: '/users/1' })
+ authenticateUser(testUser, req, res)
+ .then(() => controller(req, res).remove())
+ .catch(e => st.ok(/id parameter is required/.test(e)))
+ })
+
+ st.test('allows a user to delete their own profile', st => {
+ st.plan(1)
+ const req = request()
+ authenticateUser(testUser, req, res)
+ .then(() => {
+ req.url = '/users/' + req.userId
+ return controller(req, res).remove()
+ })
+ .then(_ => controller(req, res).getUser())
+ .catch(e => st.ok(/key not found/i.test(e)))
+ })
+
+ st.test('does not allow for deleting another person\'s profile', st => {
+ st.plan(1)
+ const req = request()
+ const req2 = request()
+ const testUser2 = {
+ username: 'bar',
+ name: 'Bar Baz',
+ password: 'Password123!',
+ email: 'bar@baz.com',
+ }
+ Promise.all([
+ authenticateUser(testUser, req, res),
+ authenticateUser(testUser2, req2, res),
+ ])
+ .then(() => {
+ req.url = '/users/' + req2.userId
+ return controller(req, res).remove()
+ })
+ .catch(e => {
+ st.ok(/forbidden/i.test(e))
+ })
+ })
+ })
+
+ t.test('#put()', function (st) {
+ st.test('requires a user payload in the request', st => {
+ st.plan(1)
+ const req = request()
+ authenticateUser(testUser, req, res)
+ .then(token => controller({
+ ...req,
+ body: undefined,
+ url: '/users/' + req.userId
+ }, res).put())
+ .catch(e => st.ok(/user payload is required/.test(e)))
+ })
+
+ st.test('requires a valid ID', st => {
+ st.plan(1)
+ const req = request({ url: '/users/1' })
+ authenticateUser(testUser, req, res)
+ .then(() => controller(req, res).put())
+ .catch(e => st.ok(/id parameter is required/.test(e)))
+ })
+
+ st.test('allows a user to update their own profile', st => {
+ st.plan(1)
+ const req = request()
+ authenticateUser(testUser, req, res)
+ .then(() => {
+ req.url = '/users/' + req.userId
+ req.body = { ...testUser, username: 'bar' }
+ return controller(req, res).put()
+ })
+ .then(_ => controller(req, res).getUser())
+ .then(user => st.notEqual(user.username, testUser.username))
+ })
+
+ st.test('does not allow for updating another person\'s profile', st => {
+ st.plan(1)
+ const req = request()
+ const req2 = request()
+ const testUser2 = {
+ username: 'bar',
+ name: 'Bar Baz',
+ password: 'Password123!',
+ email: 'bar@baz.com',
+ }
+ Promise.all([
+ authenticateUser(testUser, req, res),
+ authenticateUser(testUser2, req2, res),
+ ])
+ .then(() => {
+ req.url = '/users/' + req2.userId
+ req.body = { ...testUser, username: 'foobarbaz' }
+ return controller(req, res).put()
+ })
+ .catch(st.ok)
+ })
+
+ st.test('returns the updated user object', st => {
+ st.plan(3)
+ const ctrlr = controller(
+ request({
+ body: {
+ username: 'foo',
+ name: 'Foo Bar',
+ password: 'Password123!',
+ email: 'foo@bar.com',
+ }
+ }),
+ res
+ )
+ ctrlr.register()
+ .then(user => {
+ st.equal(typeof user, 'object')
+ st.ok(user.hasOwnProperty('id'))
+ st.ok(!user.hasOwnProperty('password'))
+ })
+ })
+ })
+})
A => lib/users/index.js +11 -0
@@ 1,11 @@
+const controller = require('./controller')
+const model = require('./model')
+const repository = require('./repository')
+const routes = require('./routes')
+
+module.exports = {
+ controller,
+ model,
+ repository,
+ routes,
+}
A => lib/users/index.test.js +18 -0
@@ 1,18 @@
+const test = require('tape')
+const feature = require('.')
+
+test('User feature', function (t) {
+ t.test('returns an object', function (st) {
+ st.equal(typeof feature, 'object')
+ st.end()
+ })
+
+ t.test('has static methods', function (st) {
+ st.equal(Object.keys(feature).length, 4)
+ st.ok(feature.hasOwnProperty('controller'))
+ st.ok(feature.hasOwnProperty('model'))
+ st.ok(feature.hasOwnProperty('repository'))
+ st.ok(feature.hasOwnProperty('routes'))
+ st.end()
+ })
+})
A => lib/users/model.js +71 -0
@@ 1,71 @@
+const assert = require('assert')
+const bcrypt = require('bcrypt')
+
+module.exports = User
+
+async function User(props = {}) {
+ return Promise.resolve()
+ .then(() => {
+ const errors = {}
+ const fields = ['name', 'username', 'email', 'password']
+
+ fields.forEach(field => {
+ const value = props[field]
+ errors[field] = errors[field] || []
+ if (!props.hasOwnProperty(field))
+ errors[field].push('is required')
+ if (typeof props[field] !== 'string')
+ errors[field].push('must be a string')
+ switch (field) {
+ case 'name':
+ if (!/^([A-Z][a-z]+([ ]?[a-z]?['-]?[A-Z][a-z]+)*)$/.test(value))
+ errors[field].push('must be a valid name')
+ break;
+ case 'username':
+ if (!/^[a-zA-Z][a-zA-Z0-9-_]{2,32}$/.test(value))
+ errors[field].push('must be a valid username (3-32 '
+ + 'characters long, including A-Z, a-z, 0-9, and _')
+ break;
+ case 'email':
+ if (!/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/.test(value))
+ errors[field].push('must be a valid e-mail address')
+ break;
+ case 'password':
+ if (!/((?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\W]).{8,32})/gm.test(value))
+ errors[field].push('must be 8-32 characters long, including '
+ + 'at least one of each of the following: an uppercase '
+ + 'letter, a lowercase letter, a number, and a special '
+ + 'character')
+ break;
+ }
+ if (!errors[field].length) delete errors[field]
+ })
+
+ if (Object.keys(errors).length) return Promise.reject(errors)
+
+ const {
+ username,
+ password,
+ email,
+ name,
+ } = props
+
+ return bcrypt.hash(password, 10)
+ .then(hashedPassword => ({
+ username,
+ hashedPassword,
+ email,
+ name,
+ }))
+ })
+}
+
+User.serialise = function serialise (user) {
+ return ({
+ username: user.username,
+ email: user.email,
+ name: user.name,
+ })
+}
+
+User.comparePassword = bcrypt.compare
A => lib/users/model.test.js +151 -0
@@ 1,151 @@
+const test = require('tape')
+const model = require('./model')
+
+test('User model', function (t) {
+ t.test('has class methods', function (st) {
+ st.equal(Object.keys(model).length, 2)
+ st.ok(model.hasOwnProperty('comparePassword'))
+ st.ok(model.hasOwnProperty('serialise'))
+ st.end()
+ })
+
+ t.test('returns a errors object', function (st) {
+ st.plan(2)
+ model({}).catch(errors => {
+ st.equal(typeof errors, 'object')
+ st.equal(Object.keys(errors).length, 4)
+ })
+ })
+
+ t.test('requires a valid username', function (st) {
+ st.plan(6)
+ model().catch(errors => st.ok(errors.username.some(e => /required/i.test(e))))
+ model({ username: 1 })
+ .catch(errors => st.ok(errors.username.some(e => /string/i.test(e))))
+ ;[
+ 'f',
+ ' ',
+ 'abcdefghijklmnopqrstuvwxyz1234567890',
+ ].forEach(username => model({ username })
+ .catch(errors => st.ok(errors.username.some(e => /valid/i.test(e))))
+ )
+ model({ username: 'foobar' }).catch(errors => st.notOk(errors.username))
+ })
+
+ t.test('requires a valid name', function (st) {
+ st.plan(8)
+ model().catch(errors => st.ok(errors.name.some(e => /required/i.test(e))))
+ model({ name: 1 })
+ .catch(errors => st.ok(errors.name.some(e => /string/i.test(e))))
+ ;[
+ 'f',
+ ' ',
+ 'foo bar',
+ 'abcdefghijklmnopqrstuvwxyz1234567890',
+ ].forEach(name => model({ name })
+ .catch(errors => st.ok(errors.name.some(e => /valid/i.test(e))))
+ )
+ model({ name: 'Foo Bar' }).catch(errors => st.notOk(errors.name))
+ model({ name: 'Foo Bar-Baz' }).catch(errors => st.notOk(errors.name))
+ })
+
+ t.test('requires a valid email address', function (st) {
+ st.plan(4)
+ model().catch(errors => st.ok(errors.email.some(e => /required/i.test(e))))
+ model({ email: 1 })
+ .catch(errors => st.ok(errors.email.some(e => /string/i.test(e))))
+ model({ email: 'fishyfishfish'})
+ .catch(errors => st.ok(errors.email.some(e => /valid/i.test(e))))
+ model({ email: 'valid.password@domain.com!' })
+ .catch(errors => st.notOk(errors.email))
+ })
+
+ t.test('requires a valid password', function (st) {
+ st.plan(7)
+ model().catch(errors => st.ok(errors.password.some(e => /required/i.test(e))))
+ model({ password: 1 })
+ .catch(errors => st.ok(errors.password.some(e => /string/i.test(e))))
+ ;[
+ '1',
+ 'password',
+ 'thisisfartoolongforourneeds.....wwwweeeeeeeeeeeeeeeeeeeee',
+ '61532765127365^%$!@',
+ ].forEach(password => model({ password })
+ .catch(errors => st.ok(errors.password))
+ )
+ model({ password: 'GoodPassword123!' })
+ .catch(errors => st.notOk(errors.password))
+ })
+
+ t.test('returned object', function (st) {
+ const user = {
+ username: 'foo',
+ name: 'Foo Bar',
+ email: 'foo@bar.com',
+ password: 'Secret5auce!',
+ }
+
+ st.test('contains a username property', function (st) {
+ st.plan(1)
+ model(user).then(u => st.equal(u.username, user.username))
+ })
+
+ st.test('contains a name property', function (st) {
+ st.plan(1)
+ model(user).then(u => st.equal(u.name, user.name))
+ })
+
+ st.test('contains an email property', function (st) {
+ st.plan(1)
+ model(user).then(u => st.equal(u.email, user.email))
+ })
+
+ st.test('does not contain a password property', function (st) {
+ st.plan(1)
+ model(user).then(u => st.notOk(u.hasOwnProperty('password')))
+ })
+
+ st.test('contains a hashedPassword property', function (st) {
+ st.plan(1)
+ model(user).then(u => st.ok(u.hashedPassword))
+ })
+ })
+
+ t.test('#serialise()', function (st) {
+ const user = {
+ username: 'foo',
+ name: 'Foo Bar',
+ email: 'foo@bar.com',
+ password: 'Secret5auce!',
+ }
+
+ st.test('removes the password properties', function (st) {
+ st.notOk(model.serialise(user).hasOwnProperty('password'))
+ st.notOk(model.serialise(user).hasOwnProperty('hashedPassword'))
+ st.end()
+ })
+ })
+
+ t.test('#comparePassword()', function (st) {
+ const user = {
+ username: 'foo',
+ name: 'Foo Bar',
+ email: 'foo@bar.com',
+ password: 'Secret5auce!',
+ }
+
+ st.test('returns true if the password is correct', function (st) {
+ st.plan(1)
+ model(user)
+ .then(u => model.comparePassword(user.password, u.hashedPassword))
+ .then(st.ok)
+ })
+
+ st.test('returns false if the password is incorrect', function (st) {
+ st.plan(1)
+ model(user)
+ .then(u => model.comparePassword('donkeyrhubarb', u.hashedPassword))
+ .then(st.notOk)
+ })
+ })
+})
A => lib/users/package.json +16 -0
@@ 1,16 @@
+{
+ "name": "users",
+ "version": "1.0.0",
+ "description": "Users feature domain",
+ "main": "index.js",
+ "private": true,
+ "dependencies": {
+ "bcrypt": "^5.0.1",
+ "level": "^7.0.1",
+ "nanoid": "^3.1.30"
+ },
+ "devDependencies": {
+ "level-mem": "^6.0.1",
+ "tape": "^5.3.2"
+ }
+}
A => lib/users/repository.js +49 -0
@@ 1,49 @@
+const { resolve } = require('path')
+const db = process.env.NODE_ENV === 'test'
+ ? require('level-mem')()
+ : require('level')(resolve(__dirname, 'db'), { valueEncoding: 'json' })
+const { nanoid } = require('nanoid')
+
+module.exports = {
+ get: id => db.get(id).then(user => ({
+ ...JSON.parse(user)
+ })),
+ findBy: (key, value) => new Promise((resolve, reject) => {
+ if (typeof key !== 'string' || !key)
+ return reject(new Error('key is required'))
+ if (key && value === undefined)
+ return reject(new Error('key value is required'))
+ return db.createReadStream()
+ .on('data', user => JSON.parse(user.value)[key] === value
+ ? resolve({ id: user.key, ...JSON.parse(user.value) })
+ : null
+ )
+ .on('error', err => reject(err))
+ .on('close', () => {})
+ .on('end', () => resolve())
+ }),
+ create: user => {
+ if (typeof user !== 'object')
+ return Promise.reject(new Error('user is required'))
+ const id = nanoid()
+ return db.put(id, JSON.stringify(user))
+ .then(() => id)
+ },
+ put: function (id, user) {
+ if (typeof id !== 'string')
+ return Promise.reject(new Error('id is required'))
+ if (typeof user !== 'object')
+ return Promise.reject(new Error('user is required'))
+ return db.put(id, JSON.stringify(user))
+ .then(() => this.get(id))
+ },
+ list: () => new Promise((resolve, reject) => {
+ const rows = []
+ return db.createReadStream()
+ .on('data', data => rows.push(data))
+ .on('error', err => reject(err))
+ .on('close', () => {})
+ .on('end', () => resolve(rows))
+ }),
+ remove: db.del.bind(db),
+}
A => lib/users/repository.test.js +112 -0
@@ 1,112 @@
+process.env.NODE_ENV = 'test'
+const test = require('tape')
+const repository = require('./repository')
+
+test('UserRepository', function (t) {
+ t.test('returns static methods', function (st) {
+ st.equal(Object.keys(repository).length, 6)
+ st.equal(typeof repository.get, 'function')
+ st.equal(typeof repository.create, 'function')
+ st.equal(typeof repository.put, 'function')
+ st.equal(typeof repository.list, 'function')
+ st.equal(typeof repository.remove, 'function')
+ st.equal(typeof repository.findBy, 'function')
+ st.end()
+ })
+
+ t.test('#get()', function (st) {
+ st.test('requires an ID', function (st) {
+ st.plan(1)
+ repository.get()
+ .catch(e => st.ok(e.message === 'key cannot be `null` or `undefined`'))
+ })
+ })
+
+ t.test('#create()', function (st) {
+ st.test('requires a user', function (st) {
+ st.plan(1)
+ repository.create()
+ .catch(e => st.ok(/user.+required/.test(e.message)))
+ })
+
+ st.test('returns the ID for the saved user', function (st) {
+ st.plan(1)
+ repository.create({ username: 'foo' })
+ .then(id => st.ok(typeof id === 'string'))
+ })
+
+ st.test('saves the new user', function (st) {
+ st.plan(1)
+ repository.create({ username: 'foo' })
+ .then(repository.get)
+ .then(st.ok)
+ })
+ })
+
+ t.test('#list()', function (st) {
+ st.test('returns an of all users', function (st) {
+ st.plan(1)
+ repository.list().then(d => st.ok(Array.isArray(d)))
+ })
+ })
+
+ t.test('#remove()', function (st) {
+ st.test('requires an ID', function (st) {
+ st.plan(1)
+ repository.remove()
+ .catch(e => st.ok(e.message === 'key cannot be `null` or `undefined`'))
+ })
+
+ st.test('removed the entry from the db', function (st) {
+ st.plan(1)
+ let userId
+ repository.create({ username: 'foo' })
+ .then(id => userId = id && id)
+ .then(repository.remove)
+ .then(() => repository.get(userId))
+ .catch(m => st.ok(/key not found in database/i.test(m.message)))
+ })
+ })
+
+ t.test('#findBy()', function (st) {
+ st.test('requires a query-by key', function (st) {
+ st.plan(1)
+ repository.findBy()
+ .catch(e => st.ok(e.message === 'key is required'))
+ })
+
+ st.test('requires a query-by value', function (st) {
+ st.plan(1)
+ repository.findBy('blah')
+ .catch(e => st.ok(/key value is required/i.test(e.message)))
+ })
+
+ st.test('returns a user object', function (st) {
+ st.plan(1)
+ repository.findBy('username', 'foo')
+ .then(st.ok)
+ })
+ })
+
+ t.test('#put()', function (st) {
+ st.test('requires a user id', function (st) {
+ st.plan(1)
+ repository.put()
+ .catch(e => st.ok(/id.+required/.test(e.message)))
+ })
+
+ st.test('requires a user object', function (st) {
+ st.plan(1)
+ repository.put('d34db33f')
+ .catch(e => st.ok(/user.+required/.test(e.message)))
+ })
+
+ st.test('returns the updated user record', function (st) {
+ st.plan(1)
+ const user = { username: 'foo' }
+ repository.create(user)
+ .then(id => repository.put(id, { username: 'foobar' }))
+ .then(updated => st.notEqual(updated.username, user.username))
+ })
+ })
+})
A => lib/users/routes.js +21 -0
@@ 1,21 @@
+module.exports = (controller, req, res) => {
+ if (controller === undefined || !(controller instanceof Object))
+ throw new Error('controller is required')
+
+ controller = controller(req, res)
+
+ if (/^\/users\/?$/.test(req.url) && req.method === 'GET')
+ return controller.list()
+
+ if (/^\/users\/([0-9a-z_-]){21}\/?$/i.test(req.url) && req.method === 'GET')
+ return controller.getUser()
+
+ if (/^\/users\/?$/.test(req.url) && req.method === 'POST')
+ return controller.register()
+
+ if (/^\/users\/([0-9a-z_-]){21}\/?$/i.test(req.url) && req.method === 'DELETE')
+ return controller.register()
+
+ if (/^\/users\/([0-9a-z_-]){21}\/?$/i.test(req.url) && req.method === 'PUT')
+ return controller.put()
+}
A => lib/users/routes.test.js +61 -0
@@ 1,61 @@
+process.env.NODE_ENV = 'test'
+const test = require('tape')
+const { routes } = require('.')
+
+const controller = (req, res) => ({
+ list: () => true,
+ getUser: () => true,
+ register: () => true,
+ put: () => true,
+})
+
+test('User routes', function (t) {
+ t.test('is a function', function (st) {
+ st.equal(typeof routes, 'function')
+ st.end()
+ })
+
+ t.test('requires a controller', function (st) {
+ st.throws(() => routes(), /controller.+required/i)
+ st.end()
+ })
+
+ t.test('matches GET /users(/)', function (st) {
+ st.ok(routes(controller, { method: 'GET', url: '/users' }, {}))
+ st.ok(routes(controller, { method: 'GET', url: '/users/' }, {}))
+ st.end()
+ })
+
+ t.test('matches GET /users/<nanoid>(/)', function (st) {
+ st.notOk(routes(controller, { method: 'GET', url: '/users/123' }, {}))
+ st.ok(routes(
+ controller, { method: 'GET', url: '/users/V1StGXR8_Z5jdHi6B-myT' }, {}
+ ))
+ st.ok(routes(
+ controller, { method: 'GET', url: '/users/V1StGXR8_Z5jdHi6B-myT/' }, {}
+ ))
+ st.end()
+ })
+
+ t.test('matches POST /users(/)', function (st) {
+ st.ok(typeof routes(controller, { method: 'POST', url: '/users' }, {}))
+ st.ok(typeof routes(controller, { method: 'POST', url: '/users/' }, {}))
+ st.end()
+ })
+
+ t.test('matches DELETE /users/<nanoid>(/)', function (st) {
+ st.notOk(routes(controller, { method: 'DELETE', url: '/users/123' }, {}))
+ st.ok(routes(
+ controller, { method: 'DELETE', url: '/users/V1StGXR8_Z5jdHi6B-myT' }, {}
+ ))
+ st.end()
+ })
+
+ t.test('matches PUT /users/<nanoid>(/)', function (st) {
+ st.notOk(routes(controller, { method: 'PUT', url: '/users/123' }, {}))
+ st.ok(routes(
+ controller, { method: 'PUT', url: '/users/V1StGXR8_Z5jdHi6B-myT' }, {}
+ ))
+ st.end()
+ })
+})
A => package.json +24 -0
@@ 1,24 @@
+{
+ "name": "user-profiles-crud-api",
+ "version": "1.0.0",
+ "description": "Simple demonstration of a CRUD API for users and their profiles",
+ "main": "index.js",
+ "private": true,
+ "directories": {
+ "lib": "lib"
+ },
+ "scripts": {
+ "preinstall": "./bin/preinstall",
+ "postinstall": "./bin/postinstall",
+ "start": "node .",
+ "test": "tape lib/**/*test.js"
+ },
+ "author": "Akiva Levy <akiva@sixthirteen.co> (http://sixthirteen.co/)",
+ "license": "ISC",
+ "dependencies": {
+ "minimist": "^1.2.5"
+ },
+ "devDependencies": {
+ "tape": "^5.3.2"
+ }
+}