Porting to Koa

Porting Bookshelf Endpoints to Koa

Koa 304,877 ships no router, so the port starts with npm 2,036 i koa @koa/router koa-bodyparser. From there it is mechanical, because Bookshelf keeps its rules in services and its HTTP concerns in thin middleware.

What changes when Bookshelf middleware moves from Express 24,430 to Koa
Express Koa Note
express.json() bodyParser() fills ctx.request.body
res.status(201).json(x) ctx.status = 201; ctx.body = x assignment, not a call
req.user, req.validBody ctx.state.user, ctx.state.body the per-request scratchpad
(err, req, res, next) try/catch around await next() registered first, not last

req.params and req.query become ctx.params and ctx.query, still strings. requireRole, validate and createBook port with three lines changed each: they read from ctx, write to ctx.state, and throw where the Express versions called next(err).

src/koa/app.js — the error boundary and two ported routesJavaScript
const problems = async (ctx, next) => {
  try {
    await next();
    if (ctx.status === 404 && !ctx.body) throw new HttpError(404, 'not_found', 'No such route');
  } catch (err) {                            // every refusal in the app lands here
    ctx.status = err.status ?? 500;
    ctx.body = { error: { code: err.code ?? 'internal', message: err.message,
      details: err.details } };
  }
};
const router = new Router({ prefix: '/api/v1/books' });
router.get('/', validate({ query: listQuery }), (ctx) => {
  const { limit, page } = ctx.state.query;
  const { rows, total } = store.list({ limit, offset: (page - 1) * limit });
  ctx.body = { data: rows, page: { limit, page, total, pages: Math.ceil(total / limit) } };
});                                          // no res, no return: just assign ctx.body
router.post('/', requireRole('editor'), validate({ body: newBook }), createBook);
new Koa().use(problems).use(bodyParser())
  .use(router.routes()).use(router.allowedMethods()).listen(4350, '127.0.0.1');

The responses are byte-for-byte what Express.js has produced all along, which is the point:

Output of 99
200 {"data":[{"id":"bk_1","title":"Solaris",...},{...}],"page":{"limit":2,"page":1,
     "total":3,"pages":2}}                                          # GET /books?limit=2
422 {"error":{"code":"validation_failed","message":"Request validation failed","details":
     [{"in":"params","field":"id","message":"not a bk id"}]}}            # GET /books/42
403 {"error":{"code":"forbidden","message":"Role editor required"}}      # POST, no token
404 {"error":{"code":"not_found","message":"No such route"}}             # GET /nope

Two traps. use(a).use(b) is chainable but order still decides everything: problems must be first, or a throw escapes to Koa's default handler. And a 404 never reaches a catch, because no route matching is not an error in Koa — it is the absence of a body, which is why problems inspects ctx.status on the way back up.