AdonisJs. Beginner’s Guide

Artem Diashkin
Mar 31, 2020 · 16 min read

This tutorial will guide you through creating Adonis fullstack-app, deploying it to Heroku, working with database, user authentication and validating data, step by step.

What will be covered in this article:

  • 1. Creating new AdonisJs project
  • 2. Configuring Heroku app
  • 3. Adding new Routes
  • 4. Adding data to the Database
  • 5. Migrations
  • 6. Controllers
  • 7. Lucid Models
  • 8. CSRF
  • 9. Security
  • 10. Data validation
  • 11. Testing

1. Creating new AdonisJs project

Install adonis/CLI

npm i -g @adonisjs/cli

Create a new project with any name you like

adonis new projectname

This will create adonis-fullstack-app

DOCS:

…, it comes pre-configured with:

Bodyparser, Session, Authentication,Web security middleware, CORS, Edge, template engine, Lucid ORM, Migrations and seeds.

You can use api-only flag to create only api server. But this is for another story.

// Scaffold project for api server
adonis new projectname --api-only
adonis new projectname --api

To start a project locally and check that everything works as expected you can use this script (will run app using .env production file)

adonis serve --dev

--dev flag means that it will restart every time if code been changed, without that flag you should restart server manually

To run adonis locally with another env file (.env.dev, for example), you can run such script

ENV_PATH=.env.dev adonis serve --dev

Before deploying to Heroku we will make a few changes. In the future you will understand “why”:

Add mysql and url-parse npm packages to package.json

npm i mysql url-parse

Add new lines in /config/database.js file:

//insert those line after Helpresconst Url = use('url-parse')
const CLEARDB_DATABASE_URL = new Url(Env.get('CLEARDB_DATABASE_URL'))
// and before module.exports {

Change MySQL configuration in /config/database.js file:

mysql: {
client: 'mysql',
connection: {
host: Env.get('DB_HOST', CLEARDB_DATABASE_URL.host),
port: Env.get('DB_PORT', ''),
user: Env.get('DB_USER', CLEARDB_DATABASE_URL.username),
password: Env.get('DB_PASSWORD', CLEARDB_DATABASE_URL.password),
database: Env.get('DB_DATABASE', CLEARDB_DATABASE_URL.pathname.substr(1))
}
},

I hope you know how to deploy your project to git, so I will skip this part and will assume that you already done it.

2. Configuring Heroku app

Create a new app

I prefer to choose auto-deploy from git master branch, but you could choose any way you like.

After connecting your repository with AdonisJs application, add ClearDB MySQL add-on to you Heroku app

Just in case -> add-on documentation

After adding ClearDB add-on you will see a new added env variable CLEARDB_DATABASE_URL in your Heroku app. As you remember I added lines of code using this env variable.

Then add DB_CONNECTION and APP_KEY environment variables in the Heroku app settings. Value of the APP_KEY you can find in the .env file of your created AdonisJs project.

If you will not add APP_KEY you could get an error with such message:

RuntimeException: E_MISSING_ENV_KEY: Make sure to define environment variable APP_KEY.App key is a randomly generated 16 or 32 characters long string required to encrypted cookies, sessions and other sensitive data.

It is recommended to add two more ClearDB databases to your Heroku add-ons, so you would have three environments:

  1. Production with a configuration in .env file;
  2. Staging with a configuration in .env.dev file;
  3. Testing with a configuration in .env.testing file;

3. Adding new Routes

Add in new lines in /start/routes.js file.

Returning simple text message

Route.get('/simple-text', () => 'Hello Adonis')

You will get a text message ”Hello Adonis” as a response in {heroku-host}/simple-text url. Simple isn’t it?

Returning html page

Route.on('/html-page').render('custom')

custom is a name of a /resources/views/custom.edge file (which is just a simple html with dynamic inclusions like in welcom.edge file. More about *.edge files you can read here

Dynamic routes

Required params

Route.get('dynamic/:id', ({ params }) => {
return `Post ${params.id}`
})

Optional params

Route.get('dynamic/:drink?', ({ params }) => {
// use Coffee as fallback when drink is not defined
const drink = params.drink || 'Coffee'
return `One ${drink}, coming right up!`
})

To know more -> routes documentation

4. Adding data to the Database

Add new lines of code to /routes.js

// Database
const Database = use('Database')
Route.get('users/:username', async ({ params }) => {
const userId = await Database
.table('users')
.insert({ username: params.username })
return userId
}).formats(['json'])

If you will try to get /users/custom-name you will get an error

Error: insert into `users` (`username`) values ('custom-name') - ER_NO_DEFAULT_FOR_FIELD: Field 'email' doesn't have a default value

Do you know why?

Because we forgot to make changes in the schema file, that was created after adonis new projectname script.

class UserSchema extends Schema {
up () {
this.create('users', table => {
table.increments()
table.string('username', 80).notNullable().unique()
table.string('email', 254).notNullable().unique()
table.string('password', 60).notNullable()
table.timestamps()
})
}
down () {
this.drop('users')
}
}

Because after we got to /users/some-name our defined schema UserSchema was created in the Heroku database.

Don’t be angry 😠 . It was done intentionally so that we could discover approaches of solving such issues :)😉

So somehow we need to fix our schema in the database (make changes to required fields email and password that are not required for our case)

In this case, we will use migrations.

From the official documentation :

Migrations are mutations to your database as you keep evolving your application. Think of them as step by step screenshot of your database schema, that you can roll back at any given point of time.

Also, migrations make it easier to work as a team, where database changes from one developer are easily spotted and used by other developers in the team.

5. Migrations

Creating schema

To create migrations you can use this script:

adonis make:migration users
> Create table // for creating new table
Select table //
for altering old table
adonis migration:run // executes up() function defined in Schema

“Create table” and “Select table” have one slight difference, and nothing more :

You can add NODE_ENV variable before the script (or ENV_PATH=/user/.env) for running migration on different databases . If you will set NODE_ENV variable to testing AdonisJs attempts to load .env.testing file from the root of your application.

For example:

NODE_ENV=testing adonis migration:run

Before making changes in the “production” database, it would be great to test out future migrations, that they would work as expected.

I do not recommend to use sqlite for testing migration purposes, because ,as official KnexJS documentation says about alter() function:

This only works in .alterTable() and is not supported by SQlite or Amazon Redshift.

We will create the same version of our “production” ClearDB Heroku database, but for “developing” purposes.

Don’t forget to copy Heroku environment database variable to .env.testing, after creating new “development” database. (Env variable name could be different from mine)

But first, let’s check migration testing status, just in case:

NODE_ENV=testing adonis migration:status

We will see something like this:

Let’s create our testing database:

$ NODE_ENV=testing adonis migration:run

If all went well, you will see a message Database migrated successfully

If you want to drop your database, run NODE_ENV=testing adonis migration:reset or NODE_ENV=testing adonis migration:rollback if it was only one action

And we have two choices: remove “email” column from schema or make it not required.

First variant:

NODE_ENV=testing adonis make:migration user
> Select table

… with updating created /database/migrations/***_user_schema.js file

class UserSchema extends Schema {
up () {
this.table('users', table => {
table.dropColumn('email')
})
}
}

or the Second variant:

NODE_ENV=testing adonis make:migration user
> Select table

and

class UserSchema extends Schema {
up () {
this.table('users', table => {
table.string('email', 254).nullable().alter()
})
}
down () {
this.table('users', table => {
table.string('email', 254).notNullable().alter()
})
}
}

To be honest, I like the second one. So let’s do it.

NODE_ENV=testing adonis migration:run

And result… Database migrated successfully

Recommendation. When you are creating migration always add logic for down() function, so your migration would work other way too -> when you execute migration:run and migration:rollback

Production

Let try it out on “production” database

adonis migration:run --force

And result… Database migrated successfully!

If you want to see queries before they will be executed, you can run

adonis migration:reset --force --log

The result would be:

Queries for ***_user_schema.js
alter table `users` modify `email` varchar(254) not null
Queries for ***_token.js
drop table `tokens`
Queries for ***_user.js
drop table `users`

Before we deploy changes to Heroku let’s make one change to our Routes.

6. Controllers

It is great that will can easily add callback function with logic to our routes.js like this:

Route.get('users/:user/:password/:email', async ({ params }) => {
const userId = await Database
.table('users')
.insert({
username: params.username,
password: params.password,
email: params.email,
});
const user = await User.find(userId)Database.close(['mysql'])return user
}).formats(['json'])

!Reminder: DONT use get methods for passing user’s personal information. This is just an example.

But with the growth of our application /start/routes.js file could become huge.

To prevent this situation in future AdodisJs provides useful functionality called Binding Controllers so our Routes could be as simple as they could be, for example:

Route.get('users', 'UserController.index')

index is just a function name in the UserController that need to be executed when user tries to reach the end-point (few example names: store, show, edit, update, destroy). To know about other controller functions you can read about them in the documentation.

class UserController {
index () {
return 'Some custom response'
}
}

Name of a function could be any you like, there is just a name conventions that are preferable

Let create a controller for Users:

adonis make:controller UsersSelect controller type 
For HTTP requests
For Websocket channel

A new app/Controllers/Http/UserController.js file will be created:

So how would a callback function look like in the UserController ?

class UserController {
customName ({ request }) {
return {
host: request.header('host'),
url: request.url(),
originalUrl: request.originalUrl(),
method: request.method(),
intended: request.intended(),
ip: request.ip(),
subdomains: request.subdomains(),
'user-agent': request.header('user-agent'),
accept: request.header('accept'),
hello: request.header('hello'),
isAjax: request.ajax(),
hostname: request.hostname(),
protocol: request.protocol()
}
}
...

and in our /start/routes.js file

Route.get('request', 'UserController.customName').formats(['json'])

Now let’s look at the result in Insomnia (or Postman, if you prefer)

7. Lucid Models

After we discovered how it works, let’s add some CRUD functions in our UserController using Lucid Inserts and Updates.

Lucid is an implementation of Active Record pattern in Javascript. If you are coming from the Laravel or the Rails world, then you may be quite familiar with it.

“official documentation”

Let’s update our UserController with new lines of code:

const User = use('App/Models/User')class UserController {
async index () {
return await User.all()
}
async show ({ params }) {
return await User.find(params.id)
}
async store () {}
async update () {}
async destroy () {}
}

/routes.js :

Route.get('users', 'UserController.index').formats(['json'])
Route.get('users/:id', 'UserController.show').formats(['json'])

Result of getting All Users and User by id:

If you will get an empty object -> don’t worry. It is because you may not added any users to your database. All works if you just get response status 200 “OK”.

Now, let’s add modifying database operations:

class UserController {
...
async store ({ request }) {
const { username, password, email } = request.post()
const user = new User()
user.username = username
user.password = password
user.email = email
await user.save()
}
async update ({ request, params }) {
const user = await User.find(params.id)
const { username, password, email } = request.post()user.username = username
user.password = password
user.email = email
await user.save()
}
async destroy ({ params }) {
const user = await User.find(params.id)
await user.delete()
}
}

and in routes.js

Route.post('users', 'UserController.store').formats(['json'])
Route
.patch('users/:id', 'UserController.update').formats(['json'])
Route.delete('users/:id', 'UserController.destroy').formats(['json'])

8. CSRF

If you will try to reach one of previously added three endpoints and try to modify the database, you will get an error :

403:Forbidden “HttpException: EBADCSRFTOKEN: Invalid CSRF token”

This is because of a CSRF (Cross-Site Request Forgery) part.

FROM DOC: Allows an attacker to perform actions on behalf of another user without their knowledge or permission.

If you just want to disable this feature, you can go to /config/shield.js file in your project and change csrf.enable to false. (not recommended if you are using not only API endpoints)

How to customize CSRF we will see next time. Now, let’s see how our functions would work after disabling CSRF feature:

CREATE
UPDATE
DELETE

Great job!

But before we will talk about CSRF, let’s optimize our Routes a little bit. You can combine all 5 CRUD User Routes in one, with the exact same result:

Route
.resource('users', 'UserController')
.apiOnly()

And even group our API Routes with adding a prefix:

Route.group(() => {
Route.get('request', 'CustomController.someCustom')
.formats(['json'])
Route
.resource('users', 'UserController')
.apiOnly()
})
.prefix('api/v1/')

And now we can investigate more about CSRF.

FROM DOC: AdonisJs protects your application from CSRF attacks by denying unidentified requests. HTTP requests with POST, PUT and DELETE methods are checked to make sure that the right people from the right place invoke these requests.

Shield middleware relies on sessions, so make sure they are set up correctly.

Good article about this topic: Should I use CSRF protection on Rest API endpoints?

So how to “fix” this? As we will use our application as API for now, we can add filterUrls to the /config/shield.js configuration

csrf: {
enable: true,
methods: ['POST', 'PUT', 'DELETE'],
filterUris: ['/api/(.*)'],
cookieOptions: {
httpOnly: false,
sameSite: true,
path: '/',
maxAge: 7200
}
}

@adonisjs/shield library with /config/shield.js file will be NOT be included if you create your app with —-api-only flag. So if you are not interested in using *.edge files as responses to url-s you can disable csrf.

9. Security

I think our application has grown enough for adding security part for user authentication.

The AdonisJs authentication provider comes pre-installed with fullstack and api boilerplates.

By default AdonisJs uses session for authorization, let change it to JWT in /config/auth.js file:

authenticator: 'jwt',
jwt: {
algorithm: 'HS256', // by default
serializer: 'lucid',
model: 'App/Models/User',
scheme: 'jwt',
uid: 'email',
password: 'password',
options: {
secret: Env.get('APP_KEY'),
expiresIn: "1m", // 1 minute
},
},

Next, modify our /start/routes.js file. Add new Route for /login path and new chain function middleware(‘auth’) to our old /users Route:

// We don't want our logged-in user to access this
Route
.
post('login', 'AuthController.login').
middleware('guest');
Route.
resource('users', 'UserController').
apiOnly().
middleware('auth');

Create new AuthController :

adonis make:controller Auth

As you noticed we should add login function to AuthController as well:

async login ({ auth, request }) {
const { email, password } = request.post();
return auth.withRefreshToken().
attempt(email, password);
}

Let’s test it out. First, let’s check that now we would not be able to get all users from our database:

And our login :

Now, we need to find some user’s email and password to login with. In my case, it is a user with email newUser2@gmail.com and with password 123.

As you can see from the screenshot, password was previously stored in the database in hashed value. It was done by the AdonisJs middleware in /app/models/User.js file.

/**
* A hook to hash the user password before saving
* it to the database.
*/

this.addHook('beforeSave', async (userInstance) => {
if (userInstance.dirty.password) {
userInstance.password = await Hash.make(userInstance.password);
}
})
;

So when the user tries to authenticate AdonisJs hashes passed password and comparing it with a database hashed password.

So let’s try to login:

I would prefer not to copy-paste token and refreshToken values to every request where authorization is needed every time I log in, so let’s use some Insomnia helpers:

And not we have two options setting the token value to other requests.

First, is to set token env variable directly to Headers:

Second, is to use Insomnia Auth menu option which will do exactly the same:

If you will add token env variable to the Login request you will get an error:

As you remember we added middleware([‘guest’]) to login Route, which means that route will return an error if user already logged in.

Let’s test our authorization functionality with GET_ALL_USERS endpoint:

And because we previously set that JWT token expiresIn: 1m , if we wait for one minute and then will try to get all users, we will get:

Let’s update login function in AuthController for refreshing token functionality:

async login ({ auth, request }) {
const { refreshToken, email, password } = request.post();
if (refreshToken) {
return await auth.
generateForRefreshToken(refreshToken);
}
return auth.withRefreshToken().
attempt(email, password);
}

And add refreshToken to Insomnia environment variables as we did previously with token. After that let’s check the results of our work:

Great job!

10. Data validation

At first, we need to add @adonisjs/validator library to our project:

adonis install @adonisjs/validator

Next, register the validator inside the /start/app.js file:

const providers = [
...
'@adonisjs/validator/providers/ValidatorProvider'
]

And after that let’s use some validations in our store() method in UserController :

class UserController {
async store({ request }) {
const rules = {
email: 'required|email|unique:users,email',
password: 'required',
};
const validation = await validate(request.post(), rules);if (validation.fails()) {
return validation.messages();
}
...
}

Let’s test it out on creating new User with wrong email data:

Validation works as expected, but error messages… could be better. We can adjust error messages by validation types, in our case by: required, email and unique:

const messages = {
'email.required': 'Email is not present in request',
'email.email': 'Enter a valid email address.',
'email.unique': 'Email is already present in db',
};

And pass messages as a third argument to validate() function:

const validation = await validate(request.post(), rules, messages);

Validate() function stops on the first error and returns it. But if we want to validate all fields we passed, validateAll() here for rescue:

const { validateAll } = use('Validator');
...
const validation = await validateAll(request.post(), rules, messages);

It looks great, but most of the time validations are repeating themselves. To prevent that we can create a new Validator and use it as Middleware. Let's create one:

adonis make:validator StoreUser

Let’s fill this /app/Validators/StoreUser.js validator with few functions:

class StoreUser {
// use validateAll function instead of validate
get validateAll () {
return true;
}
get rules () {
return {
email: 'required|email|unique:users,email',
password: 'required',
};
}
get messages () {
return {
'email.required': 'Email is not present in request',
'email.email': 'Enter a valid email address.',
'email.unique': 'Email is already present in db',
};
}
get sanitizationRules () {
return {
email: 'normalize_email',
};
}
async fails (errorMessages) {
return this.ctx.response.send(errorMessages);
}
}

Then update User Route for using StoreUser validator only on store function in UserController:

Route
.resource('users', 'UserController')
.middleware('auth')
.validator(new Map([
[[
'users.store'], ['StoreUser']],
]))
.apiOnly();

After that, you can remove all previously added validate functions in store function UserController:

async store ({ request }) {
const { username, password, email } = request.post();
const user = new User();
user.username = username;
user.password = password;
user.email = email;
await user.save();
}

And the result will be the same. Cool, isn’t it 😉?

Few words about sanitizationRules function in StoreUser validator. If user will pass email in “strange” format like this ThIs-My@gMai.COM, it will be “sanitized” to more “proper” way like: this-my@gmail.com.

As our application grows, as our application becomes more and more complex, more chances to break something. To prevent such events would be great to add some test )

11. Testing

For testing our code AdonisJs provides @adonisjs/vow library

In the beginning, we need to install it (it is NOT included in the scaffold)

adonis install @adonisjs/vow

If the script will execute as expected vowfile.js and /test folder with one test in it will be created in the root of your app:

test('make sure 2 + 2 is 4', async ({ assert }) => {
assert.equal(2 + 2, 4);
});

After that, we need to register '@adonisjs/vow/providers/VowProvider' in the /start/app.js file:

const aceProviders = [
...
'@adonisjs/vow/providers/VowProvider'
]

To run our tests we will use the command:

adonis test

If everything was configured properly you will see PASSED message after running that script.

Now let’s add some tests for our UserController .

adonis make:test UserController
Unit test
> Functional test

Step by step:

  1. We are creating a user in our test database:
const user = await User.create({
username: 'some',
email: EMAIL,
password: PASSWORD,
});

2. We are logging in by this created user:

const login = await client
.post('api/v1/login')
.send({
email: EMAIL,
password: PASSWORD,
})
.end();
// You must call "end" to execute HTTP client requests.

3. We are creating a new user, using response data token in header.

const response = await client
.post('api/v1/users')
.header('accept', 'application/json')
.header('Authorization', `Bearer ${loginResponseJson.token}`)
.send({
username: 'someName',
email: 'hello@gmail.com',
password: '123',
})
.end();

4. Our database data will role back after tests.

trait('DatabaseTransactions');

5. We are testing an API call, just like we did in Insomnia.

trait('Test/ApiClient');

That’s all! Good job!

Thank you for your attention!

If you have some questions → I will be pleased to answer 😉

LITSLINK

LITSLINK’s team is sharing their experience

Sign up for LITSLINK Newsletter

By LITSLINK

It's a test newsletter! Take a look.

By signing up, you will create a Medium account if you don’t already have one. Review our Privacy Policy for more information about our privacy practices.

Check your inbox
Medium sent you an email at to complete your subscription.

Artem Diashkin

Written by

Java, Spring, Node.js, AdonisJs, React.js and Flutter developer

LITSLINK

LITSLINK

Hands-on up to date experience sharing of the most skilled & talented software developers right here, right now. Subscribe to learn & understand more about the Software World.

Artem Diashkin

Written by

Java, Spring, Node.js, AdonisJs, React.js and Flutter developer

LITSLINK

LITSLINK

Hands-on up to date experience sharing of the most skilled & talented software developers right here, right now. Subscribe to learn & understand more about the Software World.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store