koa-from-scratch
koa-from-scratch copied to clipboard
Learn how to build a secure, RESTful API with Koa from the ground up.
koa-from-scratch
First of all, you need to initialize your project using npm init. For this,
we will use Koa 2, a next generation framework, together with
Mongorito, an awesome MongoDB mapper, and React
for the front-end.
Analog to MEAN for Mongo+Express+Angular+Node stack, I call it MKRN for Mongo+Koa+React+Node stack.
Installing Koa 2
While Koa 2 is not marked as stable, it is already great. It still depends on
some features to be implemented on Node, but we will use Babel
to circumvent that. You will need to install the next tag with NPM:
npm install --save koa@next
Differently from Express, Koa is a minimal framework and does not provide
anything besides parsing requests and serving responses. You need to provide
middlewares for routing, parsing body, templates, everything. A basic, routeless
application looks like this (call it server.js):
import Koa from 'koa'
export const app = new Koa()
app.listen(3000)
It creates a new Koa app that listens on port 3000. If you run it with Node,
either with node server.js or npm start you will see that... it breaks,
because syntax is unsupported (as of Node 6). Let's install Babel, then:
npm install --save-dev babel-cli babel-preset-{es2015,stage-0} babel-plugin-transform-runtime
Now we add a configuration file (.babelrc), with the following configuration:
{
"plugins": ["transform-runtime"],
"presets": ["es2015", "stage-0"]
}
And override default npm start behavior on package.json, under the scripts
object:
"scripts": {
"start": "babel-node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
}
Hell yeah! Launch with npm start and hit localhost:3000 on your browser. You
will see Koa automatically responding with "Not Found", of course.
Our first route
Now, let's add some routes to our app. We should only work with JSON requests
and responses, since the view layer is entirely done client-side on modern MVC
apps. Install the excellent koa-router middleware, again with
the next tag:
npm install --save koa-router@next
I like to put controllers inside a routes directory for structure purposes.
So, let's create our first controller on routes/todos.js and start by the
index:
import Router from 'koa-router'
const router = new Router()
const todos = []
router.get('/', async ctx => {
ctx.body = todos
})
export default router
Let's explain: first, you instantiate a Router object, that is provided by the
koa-router package. You can add routes to it using get, post, put and
delete functions, passing the route (in this case, / means the root of the
router) and a function that receives the context. Actually, you can have
multiple functions, as you'll see later on.
In Express, you'd probably use this as it binds functions to the context.
This is too much magic and Koa explicitly receives the context as a parameter
instead. You also receive a next function that calls the next function of the
pipeline, in case you are writting a middleware. We will use it later and you
will totally love it.
(sorry for postponing too much stuff, but Koa is just so minimal and powerful)
We now need to integrate this router with the server, and that should be easy.
Just import the router in the server.js file and hey, remember my previous
talk about middlewares? koa-router can generate a middleware for you, which
maps the request to the responding function:
import todos from './routes/todos'
(...)
app.use(todos.routes())
Easy, huh? It's worth mentioning that middlewares are called sequentially, so
put your routing at last so that other important middleware (body parsing,
templating) are called first. I suggest adding routes just above listen, see
server.js.
Fire up your server and hit localhost:3000 and you will se an empty JSON
array, which is what we defined as the body of the response.
But say we want all our todo REST under the /todos prefix. That's easy too,
import koa-router here too and replace the app.use line with the following:
const router = new Router()
router.use('/todos', todos.routes(), todos.allowedMethods())
app.use(router.routes())
That's right, we can nest routes to achieve nice functionality. Say some of your
modules (sets of REST endpoints, such as todo here) lie inside an admin
prefix, you can just keep nesting routers to achieve the desired endpoint URIs.
Pro-tip
You can toggle debugging messages with the DEBUG environment variable. To see
all Koa-related debug log, run env DEBUG=koa* npm start instead. You will also
see the regex generated for every endpoint.
Adding more routes
Now we want to be able to add, update and delete our todos. Let's add one POST
route to /todos to add one more to the list, as a REST API does, but for that
we need to parse the body of the request. The package
koa-bodyparser is our friend here:
npm install --save koa-bodyparser@next
Import it and add to the app before the routes are added:
import bodyparser from 'koa-bodyparser'
(...)
app.use(bodyparser())
Now, to access body content, use ctx.request.body on a route. By the way,
that's ES7 destructuring assignment, meaning title = ctx.request.body.title:
router.post('/', async ctx => {
const {title} = ctx.request.body
todos.push({title, completed: false})
ctx.status = 201
})
The response has status code 204 Created and empty body. You can post a new todo and go to the index again to check it has been inserted. I'll use curl for simplicity:
curl localhost:3000 -H "Content-Type: application/json" --data '{"title": "Be awesome"}'
Now hit /todos and you will see we added it :)
It needs some testing
Oh yeah, we got to the part every developer loves: unit testing. I know it should exist from the start, but I wanted to be more straightforward.
For this, we'll use ava, a minimal but very fast test runner with modern feature support, such as async functions that we already know. We will also use nyc for coverage report. We need a mock client to fake requests to the API, and for that we will use supertest, but it uses callbacks, let's use supertest-as-promised wrapper. Go get them, npm:
npm install --save-dev ava nyc supertest{,-as-promised}
Create a todos.spec.js under the test directory and import the dependencies:
import test from 'ava'
import request from 'supertest-as-promised'
import {app} from '../server'
For each test, we create a request client using the beforeEach hook:
test.beforeEach(async t => {
t.context.request = request(app.callback())
})
Every test and hook on ava has a parameter that represents test state. Modifying
the context key on it creates values that persists, so the request instance
will be accessible within tests. app.callback() will return a function that
actually processes HTTP requests, and we plug it into supertest. In fact, that
is how app.listen(3000) we used before works, but handing the callback() to
a Node.js HTTP server.
Every test on ava runs on a separate Node.js instance, so we can assume our app is on a clean state. Let's first assure the todo-list is empty:
test('listing is empty', async t => {
const {body, status, type} = await t.context.request.get('/todos')
t.is(status, 200)
t.is(type, 'application/json')
t.is(body.length, 0)
})
Ah! Don't forget we need to change some things on package.json. First, change
the test script to run ava with nyc:
"scripts": {
"start": "babel-node server.js",
"test": "nyc ava --serial"
},
Simple as that, since we are using the default file and path names for tests. Also, let's inform ava we want to use babel:
"ava": {
"babel": "inherit",
"require": "babel-register"
}
Run the test for the first time with:
npm test
And it should pass the test and output coverage status. We still do not cover the post new todo route, let's fix it:
test('create new resource', async t => {
const {status} = await t.context.request.post('/todos').send({
title: 'Be awesome'
})
t.is(status, 201)
t.is(await Todo.count(), 1)
})
And run the test again. Oh man, do you smell that 100% coverage sweet scent?
Storing with Mongorito
Mongorito is a driver for MongoDB that fits perfectly with Koa. It uses async functions so the syntax is very sweet. Also, since Mongo stores documents in a JSON-like syntax, we can use JS native objects seamlessly.
npm install --save mongorito
To use Mongorito, you need to import it in the main app file (server.js) and
connect. We'll use an environment variable to get the server URI, as it is a
common pattern around platforms (e.g. Heroku):
import Mongorito from 'mongorito'
(...)
Mongorito.connect(process.env.MONGODB_URI)
Also, import Mongorito's Model to create a new... model. For simplicity, and
since we are not having any special behavior in our model (for now), let's just
declare it on the routes file (routes/todos.js):
import {Model} from 'mongorito'
export class Todo extends Model {}
Extending Mongorito models gives you a lot of useful methods, such as find()
and save(), besides get(), set() and a constructor. We need to change the
routes to use instances of models:
router.get('/', async ctx => {
ctx.body = await Todo.find()
})
router.post('/', async ctx => {
const {title} = ctx.request.body
const todo = new Todo({title, completed: false})
await todo.save()
ctx.status = 204
})
Simple enough. If we wanted to filter todos anyhow, we would pass a filter
object to the find() method. find() is also aliased to all() for better
function naming. Suppose we want to check all completed todos:
router.get('/completed', async ctx => {
ctx.body = await Todo.find({completed: true})
})
One more change, now our tests need to cleanup the database on each test, as data won't be reset in the DB as it did with the array.
import {Todo} from '../routes/todos'
test.beforeEach(async t => {
await Todo.remove()
t.context.request = request(app.callback())
})
And that's it. Don't forget we don't need the todo array anymore, as everything
will be stored on the Mongo server. You can just run tests or spin up the server
and check with the same curl used above. Don't forget to set MONGODB_URI.
env MONGODB_URI=localhost/todos npm test
Manipulating data
Until now, the routes provided can show and create new todos, but it is missing
the ability to edit (e.g. mark as completed) and remove them. With REST APIs,
endpoints related to specific resources are generally in the form /<type>/<id>
and koa-router has a great way to deal with those routes.
Besides get() and put() we already use, we create a middleware to routes
that take parameters using param():
router.param('todo', async (id, ctx, next) => {
ctx.todo = await Todo.findById(id)
await next()
})
Awaiting next() means calling the next middleware. Later we will use
'try/catch` to check if everything worked or any error occurred.
Every route that takes a :todo will now have a bound a todo to the context.
Example, to patch() a resource:
router.patch('/:todo', async ctx => {
const {completed} = ctx.request.body
if (completed != null) {
ctx.todo.set('completed', completed)
}
await ctx.todo.save()
ctx.status = 200
ctx.body = {todo: ctx.todo}
})
It's needed to check if the body actually contains data to be updated, in our
case only completed. Then, save the mutated object, set the status code and
return body.
To test, simply insert a new item to the todo list, then send a patch with its id and see it the value is updated:
test('edit resource', async t => {
let todo = new Todo({
title: 'Be awesome'
})
await todo.save()
const {status} = await t.context.request.patch(`/todos/${todo.get('_id')}`).send({
completed: true
})
t.is(status, 200)
todo = await Todo.findById(todo.get('_id'))
t.is(todo.get('completed'), true)
})
Let's also provide a deletion route, this one is easy:
router.delete('/:user', async ctx => {
await ctx.user.remove()
ctx.status = 204
})
And to test, insert a new item, send a delete request and see if it gets deleted:
test('delete resource', async t => {
let todo = new Todo({
title: 'Be awesome'
})
await todo.save()
const {status} = await t.context.request.delete(`/todos/${todo.get('_id')}`).send({
completed: true
})
t.is(status, 204)
t.is(await Todo.count(), 0)
})
Well, that's it. Our model is complete now.
Creating some users
Now we'll get to a nice feature of Mongorito, hooks. We will model our users safely, hashing their passwords with a strong algorithm. Later on we will only let users view and create their own todos. Since the app is very minimalistic, REST is a simple pattern and we don't need no complex stuff, a user model and routes is very much like the todos.
I'll use the argon2 module (disclaimer: shameless self-promotion, I'm the
author) to properly hash and verify the passwords. Install it:
npm install --save argon2
Our user model will have username and password only and we will make sure those
fields are filled prior to saving using the save hook:
export class User extends Model {
configure() {
this.before('save', async () => {
if (!this.get('name')) {
throw new Error('Missing name.')
}
if (!this.get('password')) {
throw new Error('Missing password.')
}
})
}
async verify(password) {
return await argon2.verify(this.get('password'), password)
}
}
So, what it does is before saving, checking if both the user name and password
are not empty. As empty strings evaluate to false in JS, there's no need to
!== '' or .length == 0, but you can just in case (please don't).
Additionally, the model is extended with a nice function to check the password.
The post and patch routes need to hash the password, and the argon2 package is
fully async. You will need to provide a salt, a random value to prevent some
cracking attacks known as rainbow tables, and argon2 does it for you:
router.post('/', async ctx => {
const {name, password} = ctx.request.body
const hash = await argon2.hash(password, await argon2.generateSalt())
const user = new User({name, password: hash})
await user.save()
ctx.body = {user}
ctx.status = 201
})
router.patch('/:user', async ctx => {
const {password} = ctx.request.body
if (password != null) {
const hash = await argon2.hash(password, await argon2.generateSalt())
ctx.user.set('password', hash)
}
await ctx.user.save()
ctx.status = 200
ctx.body = {user: ctx.user}
})
See, very much like the todo and the completed field, except an extra step is
performed.
You should be able to guess how to add the other methods to users and test it.
(or just look at routes/users.js)
Tying todos and users
Only users should be able to create new todos and update their own, so we need to change our todo routes to take user data too. First, let's define a middleware that fetches name and password from the body and verifies a user exists and the password matches.
async function login(ctx, next) {
const {name, password} = ctx.request.body
const user = await User.findOne({name})
if (user.verify(password)) {
ctx.user = user.get('_id')
await next()
} else {
throw new Error('User not found.')
}
}
And, where needed, we add it before the response middleware:
router.post('/', login, async ctx => {
Don't forget to check if the user id matches the todo user id:
const {todo, user} = ctx
if (todo.get('user').toString() != user {
throw new Error('Invalid credentials.')
}