Comparing enterprise-level Node.js Frameworks Part 1: AdonisJs

For a while now, I have been using Symfony, a PHP framework, at my job to develop APIs mainly for Single-Page applications. What I was especially impressed by is the amount of code you can avoid writing yourself just by using CLI commands. Of course, there are other advantages as well as disadvantages to using frameworks which one should be aware of.

There are more frameworks out there that people use on a daily basis. Laravel (PHP) and Rails (Ruby) are just two of them that provide similar - some might say even better - developer ergonomics and can boost productivity. Since JavaScript is currently my weapon of choice, I was curious if there are alternatives in the Node.js ecosystem that can deliver a developer experience comparable to the aforementioned frameworks.

It turns out that there are a couple of candidates, which made me want to compare them to objective as well as subjective criteria. Therefore we are going to use each of these frameworks to implement an application that serves as an API and fulfills the following requirements:

  • use the ORM to define models representing Users and Expenses
  • provide the ability to seed the database with initial data for local development
  • offer full CRUD functionality for the Expense model
  • handle the uploading of files and associate them with an Expense
  • secures the CRUD routes using JWT authentication
  • returns data according to the JSON:API specification

The first framework we will look at is AdonisJs. AdonisJs follows the MVC pattern and promotes a vision of delivering stability and developer joy through a consistent and expressive API.

Setting Up

We can set up a new AdonisJs application by running the following command:

npx adonis new adonis --api-only

Since we only want to use the application as an API, we provide the --api-only parameter. This tells the installer that we don't want to use the fullstack blueprint, which would get used otherwise and includes features like templating that we won't need. Instead, the installer will refer to the API blueprint that already has API specific features configured, for example parsing request bodies or handling CORS request among others. Pretty nice!

Navigate into the created directory and add the following line to the script section in your package.json:

  "scripts": {
    "dev": "adonis serve --dev"
  },

This enables us to execute the npm run dev command and start up the application in development mode. Do just that and you will find the application served on http://127.0.0.1:3333/ or http://localhost:3333/ respectively. If you visit the URL you will see the following output:

{"greeting":"Hello world in JSON"}

This is the default route that every fresh AdonisJs application comes with. If you want to find out where exactly this output is coming from, open start/routes.js. There you will find the definition of the default route:

// start/routes.js

Route.get('/', () => {
  return { greeting: 'Hello world in JSON' }
})

Let's leave this file unmodified for now. We will come back to it in a bit.

Hooking up the database

Before we can start to define our models and routes, we need to tell AdonisJs where the database it's supposed to utilize is located. AdonisJs supports a multitude of databases out of the box. We'll be using MySQL database for our example.

Stop the development server we started earlier. First, we need to install the MySQL-driver for Node.js :

npm install mysql

Next, adjust the environment configuration by modifying the .env file and add your database connection. This can be a docker instance or - in my case - a virtual machine that serves as the database:

DB_CONNECTION=mysql
DB_HOST=mysql.framework.vm
DB_PORT=3306
DB_USER=root
DB_PASSWORD=root
DB_DATABASE=adonis

Once this is done, start up the development server again and we are good to go and start creating our models and routes.

Defining the Models

The purpose of the application we are going to build is to track expenses like rent or food. We'll start with creating a model called Expense by running the following command:

adonis make:model Expense --migration --controller

This command generated a couple of files:

  • app/Models/Expense.js is the actual model definition and represents a database table
  • database/migrations/1560080121521_expense_schema.js describes the Object-relational Mapping (ORM) of the model
  • app/Controllers/Http/ExpenseController.js is the controller related to the model

Start by modifying the newly created migration file that can be found in the database/migrations directory. Its purpose is to describe the schema of our model and is used to run the actual database migration.

Open up the file and you will find an up() method. This method gets executed once we run the migration. You can see that AdonisJs already provides us with an auto-incremented primary key called id and timestamps for creating and updating individual records. While this is a good start, we need a couple of additional fields. Modify the file to look like the following:

'use strict'

/** @type {import('@adonisjs/lucid/src/Schema')} */
const Schema = use('Schema')

class ExpenseSchema extends Schema {
  up() {
    this.create('expenses', table => {
      table.increments()
      table.string('purpose').notNullable()
      table.decimal('amount').notNullable()
      table.string('image')
      table.timestamps()
    })
  }

  down() {
    this.drop('expenses')
  }
}

module.exports = ExpenseSchema

We added three new fields to the schema. The purpose of the expense, the amount and optional image that could be the receipt.

In case you are wondering about the syntax describing the schema: Adonis builds on top of Knex.js and therefore provides a variety of column types and column modifiers.

Once you have made the modifications above, execute the database migrations:

adonis migration:run

This will execute the up() method on our new migration file and create a new table expenses according to the schema definition. If you want, connect to your database and check out the table that migration created:

mysql> describe expenses;
+------------+------------------+------+-----+---------+----------------+
| Field      | Type             | Null | Key | Default | Extra          |
+------------+------------------+------+-----+---------+----------------+
| id         | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| purpose    | varchar(255)     | NO   |     | NULL    |                |
| amount     | decimal(8,2)     | NO   |     | NULL    |                |
| image      | varchar(255)     | YES  |     | NULL    |                |
| created_at | datetime         | YES  |     | NULL    |                |
| updated_at | datetime         | YES  |     | NULL    |                |
+------------+------------------+------+-----+---------+----------------+

Additionally, since we want to secure our routes later on, we also need a model called User. Lucky for us, because this is such a common use case in modern web applications, AdonisJs already comes with a user model out of the box. You can find the definition in the related schema file in the database/migrations directory. It's nothing fancy but it will do just fine for our example.

Adding Example Data

Now that we have the structure of our database defined and applied, the next step is to seed the database. Seeding the database describes the process of adding data to it. This is especially useful for development purposes because it does not require the developer itself to add every single record and allows for easy resets on the database once something goes wrong without the danger of losing a lot of previous work.

Our goal is to add a single user that we can use for authentication and a couple of sample expenses we can use to test our controllers later on. We'll start with the user and will create a new seed file by running:

adonis make:seed User

This will create a new file database/seeds/UserSeeder.js that contains a run method. This method is supposed to hold all related operations that we need to generate our sample data and gets executed once we start the seeding. Add the following to this file:

'use strict'

const User = use('App/Models/User')

class UserSeeder {
  async run() {
    const testUser = new User()

    testUser.username = 'testuser'
    testUser.email = 'username@test.com'
    testUser.password = 'supersafepassword'

    await testUser.save()
  }
}

module.exports = UserSeeder

In this file, we create a new instance of the User model and supply a username, email, and password. Afterward, we save the instance to the database. To execute this seed file, run:

adnois seed

After you executed the seed file, you can check that the user was saved in the related database table:

mysql> select * from users;
+----+----------+-------------------+--------------------------------------------------------------+---------------------+---------------------+
| id | username | email             | password                                                     | created_at          | updated_at          |
+----+----------+-------------------+--------------------------------------------------------------+---------------------+---------------------+
|  1 | testuser | username@test.com | $2a$10$5yEcDXNwLhUxUQeR147qSOu.Mg5rZ5oI9cxdOdOzlLfk9VmTBgPhK | 2019-06-12 19:42:49 | 2019-06-12 19:42:49 |
+----+----------+-------------------+--------------------------------------------------------------+---------------------+---------------------+

The beforeSave hook of the User model in app/Models/User.js already took care of hashing the password that we provided as clear text in our seed file.

However, while this may be a quick and simple way to create a single or handful of sample instances, you can combine these seeds with so-called factories to create many instances with random data at once. We will use this concept to create a couple of expense examples. In AdoniJs, factories are defined in database/factory.js. We will start by adding a new factory for our Expense model in this file:

'use strict'

const Factory = use('Factory')

Factory.blueprint('App/Models/Expense', faker => {
  return {
    purpose: faker.word(),
    amount: faker.floating({ min: 0, max: 1000, fixed: 2 }),
  }
})

The faker object that gets passed to the factory is an instance of the library Chance which offers an API to generate random data. In our case, we use it to create random strings and floats for the purpose and amount fields respectively.

Now, we can utilize this factory to seed our database with an arbitrary number of generated expenses. To accomplish this, create another seed for the Expense model:

adonis make:seed Expense

Modify the newly created file as follows:

'use strict'

const Factory = use('Factory')

class ExpenseSeeder {
  async run() {
    await Factory.model('App/Models/Expense').createMany(5)
  }
}

module.exports = ExpenseSeeder

As you can see, we combine the seed with the factory we just created to generate 5 expenses with random data. Execute the seed file:

adonis seed --files database/seeds/ExpenseSeeder.js

Now, if you query your database you should see a result similar to this:

mysql> select * from expenses;
+----+-----------+--------+-------+---------------------+---------------------+
| id | purpose   | amount | image | created_at          | updated_at          |
+----+-----------+--------+-------+---------------------+---------------------+
|  1 | sas       | 862.68 | NULL  | 2019-06-12 20:03:22 | 2019-06-12 20:03:22 |
|  2 | aginiluru | 661.96 | NULL  | 2019-06-12 20:03:22 | 2019-06-12 20:03:22 |
|  3 | karawef   | 520.65 | NULL  | 2019-06-12 20:03:22 | 2019-06-12 20:03:22 |
|  4 | piid      | 640.58 | NULL  | 2019-06-12 20:03:22 | 2019-06-12 20:03:22 |
|  5 | jitil     | 418.95 | NULL  | 2019-06-12 20:03:22 | 2019-06-12 20:03:22 |
+----+-----------+--------+-------+---------------------+---------------------+

Since we now have some sample data, let's implement the actual API by adding the routes and necessary controller actions to read this data, update, delete or add new records.

Implementing the CRUD API

Earlier, when we created the model Expense model, we passed an additional parameter to the install command in --controller which already created a controller file for us. If you look inside the file, you'll notice a couple of methods that AdonisJs already provides by default.

Because all our controller actions will need the corresponding model we start by adding the import on top of the file:

const Expense = use('App/Models/Expense')

class ExpenseController {
// ...
}

For our use case, in which we only use the application as an API and do not care about views and templates, we need to implement the methods for the following HTTP requests:

GET /expenses - show multiple expenses

async index({ request, response, view }) {
  const expenses = await Expense.all()

  return response.json(expenses)
}

Here, we fetch all the expense instances in our database and return them as a JSON response. Of course, in a more sophisticated application, you would want to implement further functionality like filtering or pagination through query parameters, but for now, this is good enough.

GET /expenses/:id - show a single expense by id

async show({ params, request, response, view }) {
  const { id } = params
  const expense = await Expense.find(id)

  return response.json(expense)
}

In order to fetch a single expense instance, we extract the dynamic segment id out of the params object and use it to load the corresponding expense. Afterward, the expense is sent to the user in JSON format.

POST /expenses - create a new expense

async store({ request, response }) {
  const { purpose, amount } = request.all()

  const expense = new Expense()
  expense.purpose = purpose
  expense.amount = amount

  await expense.save()

  return response.json(expense)
}

To create a new expense, we extract the purpose and amount out of the request payload using the request.all() helper. We then instantiate a new instance and assign the values to it before saving it to the database. Afterward, we include the newly created expense in the response.

PUT/PATCH /expenses/:id - update an existing expense

async update({ params, request, response }) {
  const { purpose, amount } = request.all()
  const { id } = params
  const expense = await Expense.find(id)

  expense.purpose = purpose
  expense.amount = amount

  await expense.save()

  return response.json(expense)
}

To update an existing expense, similar to the show() method we extract the dynamic segment id to load the right expense and set the purpose and amount fields with the values we provided in the request payload.

DELETE expenses/:id - delete an expense

async destroy({ params, request, response }) {
  const { id } = params
  const expense = await Expense.find(id)

  await expense.delete()

  return response.json({ success: true })
}

Again, we extract the id, load the relevant expense, then delete it in our database and respond with a message that the deletion was successful.

Hook up the controller with the routes

While we have implemented the necessary controller methods to provide CRUD functionality for the Expense model, they are still not available from the outside. We can accomplish that by defining the routes and hooking them up to the controller.

Since CRUD is a common use case, AdonisJs offers a nice shorthand to register all the routes at once that correspond to the methods we implemented above. Open up the start/routes.js file we already looked at in the beginning and add the following line at the end:

Route.resource('expenses', 'ExpenseController').apiOnly()

The addition of apiOnly() is another nice shorthand to remove all view-related routes that we don't need because we don't care about templating right now.

Once that is all done, we can send a couple of requests to check that our routes work. I would recommend Postman as a convenient tool to test APIs, but you can also opt into plain curl requests:

curl -X GET \
  http://localhost:3333/expenses/ \
  -H 'Content-Type: application/json' 

curl -X POST \
  http://localhost:3333/expenses/ \
  -H 'Content-Type: application/json' \
  -d '{
    "purpose": "Test Expense",
    "amount": 732.12
}'

While the response to the first request will contain all the available expenses, the second request will create a new expense with the purpose "Test Expense". Test for yourself!

Handling File Uploads

Next, after we implemented basic CRUD functionality for our Expense model, I want us to have a look at another common use case in web applications - file uploads.

You might have spotted that earlier - when we defined our models - we included another field besides purpose and amount to the model called image. This field will come in handy now and offer us a place to associate an image to a selected expense. Lucky for us, AdonisJs makes handling file uploads really easy.

First, we have to register a new route at the end of the start/routes.js file and assign it to the upload() method that we are going to implement in the expense controller in a bit:

 Route.resource('expenses', 'ExpenseController')
+Route.post('expenses/:id/upload', 'ExpenseController.upload')

Next, we'll add the new method to our controller:

async upload({ params, request, response }) {
  const { id } = params
  const expense = await Expense.find(id)

  if (!expense) {
    return response.json({ success: false })
  }

  const receiptImage = request.file('receipt')
  const imageName = receiptImage.clientName

  const Helpers = use('Helpers')
  await receiptImage.move(Helpers.publicPath('uploads'), {
    name: imageName,
    overwrite: true,
  })

  if (!receiptImage.moved()) {
    return receiptImage.error()
  }

  expense.image = imageName
  await expense.save()

  return response.json(expense)
}

Let's break down what happens here: Just like with other routes that acted on a specific expense, we pull out the id parameter out of the request and load the related expense instance, if available. Next, we use the .file() method on the request object and grab a file named receipt and save the name of the uploaded image in a variable for later use.

On a side note, usually, when you use the server as an API only, you would probably use some kind of asset server or cloud storage. Here, we will just use the public directory of the application for demonstration purposes. To accomplish that, we import a Helper object that offers a set of handy helper functions (duh). Then, we use this helper to move the image into a directory called uploads inside the public directory.

We could also modify the name of the file that we save, for instance giving it a unique name, but for simplicity's sake, we will forgo this for now. There are a variety of other validation options AdonisJs offers to ensure only valid files are uploaded.

Once we checked that the moving was successful, we save the name to the image field of the expense we loaded before, so we can reference it later when you load the expense again, and save it to the database.

Now, testing a file upload with pure curl requests is possible but I would - again - recommend Postman which offers a fairly straight forward way to test file uploads.

Note: The key you associate the file with should be named receipt since that is what we are looking for in the upload() method we implemented above.

Once you successfully uploaded an image, you should find it in the public/uploads directory. Another way to test the upload is to visit http://localhost:333/uploads/<myImageName>. However, for this to work, you first have to enable the Static Middleware. You can do that by adding the middleware in start/kernel.js:

const serverMiddleware = [
-  // 'Adonis/Middleware/Static',
-  'Adonis/Middleware/Cors',
+  'Adonis/Middleware/Static',
+  'Adonis/Middleware/Cors'
]

After you have done that, you can inspect your files right inside the browser.

Adding Authorization using JSON-Web-Tokens

With CRUD and file upload functionality done, we can have a look at how to secure our API and protect our routes from unauthorized access. AdonisJs supports a variety of stateful and stateless authentication methods through its Authentication Provider.

We will use a stateless authentication method by utilizing a standardized technology called JSON Web Tokens (JWT) to determine if an API request is valid. Upon successful login, a user will receive a token that he uses for subsequent requests to authenticate himself.

Start by registering the authentication provider as a global middleware in start/kernel.js:

const globalMiddleware = [
   'Adonis/Middleware/BodyParser',
   'App/Middleware/ConvertEmptyStringsToNull',
+  'Adonis/Middleware/AuthInit',
]

Now for the awesome part - since we initialized our application with the --api-only parameter at the beginning - JWT is already configured as the default authentication method. If you want to check, open up config/auth.js and watch out for the following line which configures what authentication method is used:

module.exports = {
  // ...

  authenticator: 'jwt',

  // ...
}

The file also contains the standard configuration for JWT based authentication below which pairs up nicely with the default User model that was already provided by default as well:

jwt: {
  serializer: 'lucid',
  model: 'App/Models/User',
  scheme: 'jwt',
  uid: 'email',
  password: 'password',
  options: {
    secret: Env.get('APP_KEY'),
  },
},

Next, we have to provide the functionality that takes care of actually authenticating a request, for instance by "logging in", and responds with a JWT if the request payload was valid. To accomplish that, create a new controller:

adonis make:controller --type http AuthController

We will implement a login() method that a user can send a POST request including his credentials, in that case, the email and the password, to:

'use strict'

class AuthController {
  async login({ request, auth, response }) {
    const { email, password } = request.all()

    try {
      const token = await auth.attempt(email, password)

      return response.json(token)
    } catch (e) {
      return response.status(400).json({ message: 'Invalid email/password' })
    }
  }
}

module.exports = AuthController

The credentials get extracted out of the request body and are used for authentication using the attempt() method on the auth object that AdonisJs provides to the controller. If the attempt is successful, we return the generated token in our response which the user can use for subsequent requests. If it fails, we just return a status code of 400 with a custom message.

Now, to actually protect our routes, we have to modify our existing routes and add the auth middleware to them:

-Route.resource('expenses', 'ExpenseController')
-Route.post('expenses/:id/upload', 'ExpenseController.upload')
+Route.resource('expenses', 'ExpenseController').middleware(['auth'])
+Route.post('expenses/:id/upload', 'ExpenseController.upload').middleware(['auth'])

If you try to send a request to one of the routes like we did before, you will now see that you get a 401 Unauthorized status code:

curl -X GET -I http://localhost:3333/expenses

HTTP/1.1 401 Unauthorized
Content-Type: text/html; charset=utf-8

In order to get a successful response, you would need to first login using valid credentials. If you remember, earlier when we seeded our database, we already added a user to it that we can now use for that purpose. However, we have to make the login method available to the outside first. We can do so by adding the controller to start/routes.js:

Route.post('login', 'AuthController.login')

Use the new route with the credentials of our user and request a token through the API:

curl -X POST \
  http://localhost:3333/login \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "username@test.com",
    "password": "supersafepassword"
}'

{"type":"bearer","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsImlhdCI6MTU2MDM2NDc3N30.T1rNJ5LYg69DTfWMefmdkC6NPV5uG2Ay3YS970rwlIg","refreshToken":null}

You can now use the token and add an Authorization Header to the request that did not get through the authentication middleware before and get the expected response:

curl -X GET \
  http://localhost:3333/expenses/ \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsImlhdCI6MTU2MDM2NDc3N30.T1rNJ5LYg69DTfWMefmdkC6NPV5uG2Ay3YS970rwlIg' 

Implementing the JSONAPI Specification

Last but not least, after implementing and protecting our routes and controllers, we want to return our data according to the JSON:API specification. Lucky for us, there is already a library that wraps the commonly used Node.js module json-api-serializer specifically for applications built with AdonisJs. We can ulitize that to implement the specification fairly easy.

Stop the development server and install the library:

npm install @dinevillar/adonis-json-api-serializer

After installing it we need to add some configuration to make everything work. First, we have to add a new provider in start/app.js:

const providers = [
   '@adonisjs/bodyparser/providers/BodyParserProvider',
   '@adonisjs/cors/providers/CorsProvider',
   '@adonisjs/lucid/providers/LucidProvider',
+  '@dinevillar/adonis-json-api-serializer/providers/JsonApiProvider',
]

Second, create a new file called config/jsonApi.js and register the Expense model there since we want our expenses to be serialized according to the specification:

module.exports = {
  globalOptions: {
    convertCase: 'snake_case',
    unconvertCase: 'camelCase',
  },
  // Register JSON API Types here..
  registry: {
    expense: {
      model: 'App/Models/Expense',
      structure: {
        links: {
          self: data => {
            return '/expenses/' + data.id
          },
        },
        topLevelLinks: {
          self: '/expenses',
        },
      },
    },
  },
}

Third, we have to add our new JSON:API serializer to the Expense model:

 const Model = use('Model')

 class Expense extends Model {
+  static get Serializer() {
+    return 'JsonApi/Serializer/LucidSerializer'
+  }
 }

And that's it! Send another request:

curl -X GET http://localhost:3333/expenses

…and you will get a response conforming the specification:

{
    "jsonapi": {
        "version": "1.0"
    },
    "meta": {
        "total": 12
    },
    "links": {
        "self": "/expenses"
    },
    "data": [
        {
            "type": "expense",
            "id": "2",
            "attributes": {
                "purpose": "Test Expense",
                "amount": 732.12,
                "image": "myImage.png",
                "created_at": "2019-06-12 20:03:22",
                "updated_at": "2019-06-12 20:54:36"
            },
            "links": {
                "self": "/expenses/2"
            }
        },
        ...
    ]
}

Awesome!

Conclusion

I had a really good time using AdonisJs and we managed to implement all of the features I had in mind. Of course - just like any other framework - it took a bit of getting used to its conventions but the documentation around all of the topics that were covered in this article was really useful.

In general, I think that the documentation is excellent and a noteworthy asset of AdonisJs. It definitely sets the bar quite high for the framework I want to have a look at in the next post of this series and I'm excited to see how they will compare.

See you then!

Thank you for reading! If you have feedback of any kind or just want to have a chat, feel free to reach out to me. You can find my contact information below.