DEV Community

Maftuleac Liviu
Maftuleac Liviu

Posted on • Originally published at lmaftuleac.github.io

Schnapps.js - a library that simplifies your backend code

I want to share my experience as a back-end developer and a team leader and how a simple shift in our approach to write backend code turned out to be very efficient for the team. Not only we decreased the development effort, but it also helped increase the code readability which helped a lot on those Pull Requests. I decided to make it public for the community, so here it is - Schnapps.js.
Schnapps.js is library that complements a framework. It moves away from the classic controller/service patterns, which may be tricky at the beginning, but once you get used to it, you'll be snapping apps like snapping fingers!

The Reason - I never liked how inconsistent the architectures can be when it comes to back-ends in Node.js. It looks like every project has its own way of organizing things. Even in a company with a strong coding guidelines, the project structures are very different, and switching form one project to another can be such a pain in the ass.

Generally speaking, in most cases the architecture follows the usual controller/service pattern. But the big difference here is how we handle communication between controllers and services. Do our services return promises? or should we use Monet? How should we handle errors? are we going to use Ramda for handling service outputs ? Because there are so many libraries that you can choose from, things can get messy real quick especially when you handle two or more projects concomitantly which have different architecture.

Another problem with the controller/service pattern as I see it - a controller is a function responsible for one particular task. This means that a controller cannot be reused. Even if there are two similar routes that have a slight difference - let's say that we have an API route which is used by USERS to check their balance, and there is a similar route used by ADMINISTRATORS that can view a user's balance. The main difference here is that one controller should take its userId value from session, while the second one should take it from query params. In most cases you will end up duplicating the same controller, making that small adjustment.

With Schnapps we can get rid of any 3rd party library to handle communication between controllers and services, and to maximize reusability of the code, so you don't have to duplicate the code over and over again, which unfortunately happens quite often.

I liked the idea of middleware in Express, so I decided to extrapolate this concept. In essence an API is like a library - you have a set of methods, you call a method with some input data and receive an output. Each method is composed of a set of consecutive steps. For example, when a user requests a secured route to get some data, the steps will be:

1. Validate user's token
2. Validate if user has rights access the route
3. Query the database
4. Send a response

We can consider this set of consecutive steps - a pipeline. And this is the actual concept of Schnapps library - you split your request-response cycle into a pipeline of small and comprehensible tasks.
Each task in the pipeline is represented by a handler function.
A handler function accepts four parameters:

const handler = (req, res, next, errorCb, data) => {
    /**
     * req - request object
     * res - response object
     * next - callback to trigger next handler in the pipeline
     * errorCb - callback for throwing errors
     * data - optional parameter, passed by previous handler
     */
}

Unlike Express, Schnapps handlers use next to pass control AND data to the next handler (i.e.next(data)). The next handler in the pipeline will receive data as the forth parameter.

A controller pipeline, is created by invoking a controller constructor function using @schnapps/core library

const  { controller } = require('@schnapps/core')
// create a new controller
const SchnappsController = controller()

// add handlers
SchnappsController
  .do(handler)
  .do(handler)
  .do(handler)
  .end((req, res, errorCb, data) => {
    // send a response
  })
  .catch((req, res, error) => {
    // any exceptions or errors triggered above will end up here
  })

// connect the pipeline to Express
express.get('/', (req, res) => SchnappsController(req, res, {data: 'some-initial-data'}))

Request and Response objects depend on the framework used. Schnapps does not interact with those objects, it just passes them as input to its handlers. Therefore if Schnapps is connected to Hapi, each handler will receive request and h as first and second parameter respectively.

// connect the pipeline to Hapi
server.route({
    method: 'GET',
    path:'/',
    handler: (request, h) => SchnappsController(request, h, {data: 'some-initial-data'})
});

In this next example we'll create an authentication mechanism
using Schnapps with Express. We'll start by defining the handlers first, then connect them to the controllers.


/**
 * 1. Parsing and substracting Bearer Token
 */

const parseAuthorizationHeader = (req, res, next, errCb, data) => {
    // Check whether an authorization header is present
    const { headers } = req;
    if (!headers.authorization) {
      // Terminate flow with a 401 error
      return errorCb({
        code: 401,
        message: 'Missing Authorization Header'
      });
    }

    // subtract our session token
    const match = headers.authorization.match(/^Bearer (.*)$/)
    if (!match) {
      // bad Header
      return errorCb({
        code: 401,
        message: 'Bad Authorization Header Format'
      });
    }
    const token = match[1];
    return next({ token })
}

/**
 * 2. Decode Token, subtract userId and role
 */

const decodeJwtToken = async (req, res, next, errCb, { token }) => {
  try {
    const { userId, role } = await jwtVerify(token, JWT_SECRET);

    // pass role value to the next handler
    return next({ role });
  } catch(error) {

    if (error.name === 'TokenExpiredError') {
      return errCb({
        code: 401,
        message: 'Session Expired'
      });
    }

    return errCb({
      code: 401,
      message: 'Bad Authentication Token'
    });
  }
}

/**
 * 3. Access based on user role: we'll use one of these handlers to limit user access
 */

const userAccess = (req, res, next, errCb, { role }) => {
  const accessLevel = ['USER','MAGANGER','ADMIN'];
  if ( accessLevel.contains(role) ) {
    return next({ role });
  } else {
    errorCb({
      code: 403,
      message: 'Forbidden'
    })
  }
}

const managerAccess = (req, res, next, errCb, { role }) => {
  const accessLevel = ['MAGANGER','ADMIN'];
  if ( accessLevel.contains(role) ) {
    return next({ role });
  } else {
    errorCb({
      code: 403,
      message: 'Forbidden'
    })
  }
}

const adminAccess = (req, res, next, errCb, { role }) => {
  const accessLevel = ['ADMIN'];
  if ( accessLevel.contains(role) ) {
    return next({ role });
  } else {
    errorCb({
      code: 403,
      message: 'Forbidden'
    })
  }
}

// import schnapps constructor
const { controller } = require('@schnapps/core')

// create new controller
const AccessController = controller()

// add handlers
AccessController
  .do(parseAuthorizationHeader)
  .do(decodeJwtToken)

// controllers and handlers can be passed to the constructor
const UserAccess = controller(AccessController, userAccess);
const ManagerAccess = controller(AccessController, managerAccess);
const AdminAccess = controller(AccessController, adminAccess);
...

const DoSomethingAsAdmin = controller(AdminAccess);

DoSomethingAsAdmin
  .do((req, res, next, errCb, data) => {
    // do something here as admin
    ...
    next('any data')
  })
  .end((req, res, errCb, data) => {
    // submit a response
  })
  .catch((req, res, error) => {
    // Any errors triggered above will endup here
  })

// connect it to Express
app.post('/admin/route', 
   (req, res) => DoSomethingAsAdmin(req, res, { any: 'data' }))

Now here are some cool part of Schnapps controllers:

  • controllers can inherit handlers from other controllers
const  { controller } = require('@schnapps/core')

const FirstController = controller();
FirstController
  .do(handler1)
  .do(handler2)

const SecondController = controller();
SecondController
  .do(handler3)
  .do(handler4)

const AggregatedController = controller(FirstController, SecondController);
  • controllers can include other controllers
const  { controller } = require('@schnapps/core')

const ChildController = controller();
ChildController
  .do(handler1)
  .do(handler2)

const MainController = controller();
MainController
  .do(ChildController)
  .do(handler3)
  .do(handler4)
  • using next() to control and redirect the flow
const  { controller } = require('@schnapps/core')

const FirstController = controller();
FirstController
  .do(handler1)
  .do(handler2)

const SecondController = controller();
SecondController
  .do(handler3)
  .do(handler4)

const ThirdController = controller();

ThirdController.do((req, res, next, errorCb, data) => {
  if (condintion) {
    return next(FirstController, data)
  } else {
    return next(SecondController, data)
  }
})

  • a controller can be converted to a promise
const  { controller } = require('@schnapps/core')

const SchnappsController = controller();
SchnappsController
  .do(handler1)
  .do(handler2)

express.get('/', async (req, res) => {
  const dataReturnedByLastHandler = 
    await SchnappsController.promise(req, res, {
      data: 'some-initial-data'
    }))
})

There are more features which I will not be covering here, but you can check this Guide for more details.

This concept allowed us to create reusable code blocks. We took things a little further and created a set of common blocks that can be re-used in other applications, and extended as needed, like Authentication module.

Now going back to my original problem - when me and my team started using this concept, we removed the dependency of most libraries that where adding complexity to the code. Now all our Service methods have the same structure: each service method represents a handler, it's easy to test and easy to read. We increased the overall readability of the code, got read of any potential pyramids of doom, and what's most important - we decreased the development effort by nearly 30%.

I hope this library will make your life easier.
Let me know your opinion. Comments and suggestions are more than welcome.
Cheers!

Schnapps.js
Guide
API Docs

Liviu.

Top comments (1)

Collapse
 
pavelloz profile image
Paweł Kowalski

you split your request-response cycle into a pipeline of small and comprehensible tasks.

I think they are called middleware, usually :)