Plz yurn on JavaScript

Build a REST API with AdonisJs and TDD Part 3

June 02, 20194 minutes to read

  • #tutorial
  • #adonisjs
  • #tdd
  • #javascript
  • #testing

Intro

In this part, we jump straight back to our challenges API endpoint where we will add a way to a user to fetch all his own challenges. Also would be nice if the user can update and delete an own challenge.

Get /api/me/challenges

First thing create a new functional test by running

1adonis make:test GetUserChallenges

In the test, we will write it in one go.

1'use strict'
2
3const Factory = use('Factory')
4const { test, trait } = use('Test/Suite')('Get User Challenges')
5
6trait('Test/ApiClient')
7trait('Auth/Client')
8
9test('can get all the user challenges', async ({ assert, client }) => {
10 const user = await Factory.model('App/Models/User').create()
11 const otherUser = await Factory.model('App/Models/User').create();
12 const challenges = await Factory.model('App/Models/Challenge').makeMany(2)
13 const otherChallenges = await Factory.model('App/Models/Challenge').makeMany(2)
14
15 await user.challenges().saveMany(challenges)
16 await otherUser.challenges().saveMany(otherChallenges)
17
18 const response = await client
19 .get('/api/me/challenges')
20 .loginVia(user, 'jwt')
21 .end()
22
23 response.assertStatus(200)
24
25 assert.equal(response.body.length, 2);
26
27 response.assertJSONSubset([
28 { title: challenges[0].title },
29 { title: challenges[1].title }
30 ])
31})

This test start we 2 user. One who will be our, and one different user. We also make 2 challenges for us and 2 for the other user.

We make sure here don't save it right to the DB. We want to be able to add the relation with the user.

So we add the challenges to the user with the saveMany method who batch save those challenges. We do the same with the other user.

We create a response where we log the user with JWT. After this, we check for a status 200 Ok. Also, we want to make sure I just receive 2 challenges, no more, no less. I don't want this endpoint to return me challenges from a others user. I add the last check to make sure the 2 challenges we got are the one in the challenges variables.

If you run the test with adonis test or yarn test you will get 404 error. Remember this mean routes not exist. So jump to the file routes.js and add this line.

1Route.get('/api/me/challenges', 'MeController.challenges').middleware(['auth'])

Here this route is nothing too strange, we make sure user is authenticated by using the middleware auth. We did that already :) Only thing change is I make use of another controller call MeController. I can have put it inside the ChallengeController but the thing is I like the controller to look like the route's path.

You can create a controller by running

1adonis make:controller Me

Go inside the new file created and add this code to the class

1async challenges() {
2
3}

Now your test will have error cause we return nothing etc. Time to add the logic, and wow Adonis make your life soooo easy.

1class MeController {
2 async challenges({ response ,auth}) {
3 const user = await auth.getUser();
4
5 const challenges = await user.challenges().fetch();
6
7 return response.ok(challenges.toJSON());
8 }
9}

First, we need to get the current user. By using the auth.getUser function we can get it. After this to get the challenges we can then ask the user to fetch all the challenges owned. This is possible cause of the user model we have done in the first part.

1challenges() {
2 return this.hasMany('App/Models/Challenge')
3}

This challenges method inside the User model gives us the one owned by the user. The thing is those challenges will not be in JSON format so that's why inside the response we ask the toJSON method.

Now if you run your test all should be green :)

Put /api/challenges/:id

Now time to work on the update endpoint. First, create a new test

1adonis make:test UpdateChallenge

We will need to test here, the first one is to make sure a user who is the author of the challenge can update it and see the change. The second test is to make sure we don't let other users update a challenge.

1'use strict'
2
3const Factory = use('Factory')
4const { test, trait } = use('Test/Suite')('Update Challenge')
5
6trait('Test/ApiClient')
7trait('Auth/Client')
8
9test('a user can update a challenge owned', async ({ client }) => {
10 const user = await Factory.model('App/Models/User').create()
11 const challenge = await Factory.model('App/Models/Challenge').make()
12
13 await user.challenges().save(challenge)
14
15 const data = {
16 title: 'This is my new title'
17 }
18
19 const response = await client
20 .put(`/api/challenges/${challenge.id}`)
21 .loginVia(user, 'jwt')
22 .send(data)
23 .end()
24
25 response.assertStatus(200)
26
27 response.assertJSONSubset({
28 id: challenge.id,
29 title: data.title
30 })
31})

For the first test, this is pretty simple. We first create a user and link the challenge. We then create a data object who will contain the new title. We then use the client and send to the endpoint this data. We check the response to make sure this is 200 ok and also the JSON contains the same id and the new title.

Run test, see it fail. Time to create the route first.

1Route.put('/api/challenges/:id', 'ChallengeController.update')
2 .validator('UpdateChallenge')
3 .middleware(['auth'])

The route is pretty simple, but we add a validator. I will not do the test for this cause this is pretty easy and I want to give you more on the business logic.

For creating the validator just run

1adonis make:validator UpdateChallenge

And inside this one paste that

1'use strict'
2
3class UpdateChallenge {
4 get rules() {
5 return {
6 title: 'string',
7 description: 'string'
8 }
9 }
10
11 get messages() {
12 return {
13 string: '{{ field }} is not a valid string'
14 }
15 }
16
17 get validateAll() {
18 return true
19 }
20
21 async fails(errorMessages) {
22 return this.ctx.response.status(400).json(errorMessages)
23 }
24}
25
26module.exports = UpdateChallenge

This is like the CreateChallenge validator but nothing is required.

Inside your ChallengeController now add this method

1async update({ response, request, params, auth }) {
2 const user = await auth.getUser()
3
4 const challenge = await Challenge.findOrFail(params.id)
5
6 if (challenge.user_id !== user.id) {
7 throw new UnauthorizedException();
8 }
9
10 challenge.merge(request.only(['title', 'description']));
11
12 await challenge.save();
13
14 return response.ok(challenge)
15}

This update method will first get the user. Then find the challenge. This will return a free 404 if the challenge doesn't exist. After this, we check for the user_id key in the challenge to see if that match the current user. If not we throw an Exception.

Time to make the exception

1adonis make:exception UnauthorizedException
1'use strict'
2
3const { LogicalException } = require('@adonisjs/generic-exceptions')
4
5class UnauthorizedException extends LogicalException {
6 handle(error, { response }) {
7 response.status(401).send('Not authorized')
8 }
9}
10
11module.exports = UnauthorizedException

This one will return a 401 with the message Not authorized.

After this, if the user is the author we merge the request object for only title and description. Only fields we accept an update.

We make sure to save the challenge, if not this will not persist. And finally, we return this challenge with the status 200.

If you run the test all should be green. But we need to make sure a nonauthor cannot update.

1test('cannot update challenge if not the author', async ({
2 assert,
3 client
4}) => {
5 const user = await Factory.model('App/Models/User').create()
6 const otherUser = await Factory.model('App/Models/User').create()
7 const challenge = await Factory.model('App/Models/Challenge').make()
8
9 await otherUser.challenges().save(challenge)
10
11 const data = {
12 title: 'This is my new title'
13 }
14
15 const response = await client
16 .put(`/api/challenges/${challenge.id}`)
17 .loginVia(user, 'jwt')
18 .send(data)
19 .end()
20
21 response.assertStatus(401)
22
23 const _challenge = await use('App/Models/Challenge').find(challenge.id)
24
25 // check if the title really didn't change
26 assert.notEqual(_challenge.title, data.title)
27})

All should be green :)

Time to work on the delete portion

1adonis make:test DeleteUserChallenge

You must be good now with the basic stuff :) Lot of repetitive think here, but you win a lot of trust in your project.

1'use strict'
2
3const Factory = use('Factory')
4const { test, trait } = use('Test/Suite')('Delete Challenge')
5
6trait('Test/ApiClient')
7trait('Auth/Client')
8
9test('a user can delete a challenge owned', async ({ client }) => {
10 const user = await Factory.model('App/Models/User').create()
11 const challenge = await Factory.model('App/Models/Challenge').make()
12
13 await user.challenges().save(challenge)
14
15 const response = await client
16 .delete(`/api/challenges/${challenge.id}`)
17 .loginVia(user, 'jwt')
18 .end()
19
20 response.assertStatus(204)
21})
22
23test('cannot delete challenge if not the author', async ({
24 assert,
25 client
26}) => {
27 const user = await Factory.model('App/Models/User').create()
28 const otherUser = await Factory.model('App/Models/User').create()
29 const challenge = await Factory.model('App/Models/Challenge').make()
30
31 await otherUser.challenges().save(challenge)
32
33 const response = await client
34 .delete(`/api/challenges/${challenge.id}`)
35 .loginVia(user, 'jwt')
36 .end()
37
38 response.assertStatus(401)
39
40 const _challenge = await use('App/Models/Challenge').find(challenge.id)
41
42 assert.isNotNull(_challenge)
43})

First, we will test a current user who owns the challenge can delete it. It's almost a copy and paste of the update method. Same for the version where the user cannot delete a challenge if not own.

For the routes now you should add

1Route
2 .delete('/api/challenges/:id', 'ChallengeController.delete')
3 .middleware([
4 'auth'
5 ])

And for your controller, it's easy like that

1async destroy({ response, params, auth }) {
2 const user = await auth.getUser()
3
4 const challenge = await Challenge.findOrFail(params.id)
5
6 if (challenge.user_id !== user.id) {
7 throw new UnauthorizedException();
8 }
9
10 await challenge.delete()
11
12 return response.noContent();
13 }

Remember findOrFail give you a free 404 if the challenge doesn't exist. We need to just throw 401 exceptions if the user is not the author.


The routes file

If you look right now at your routes file this will look something like that

1Route.get('/api/challenges', 'ChallengeController.all')
2Route.get('/api/challenges/:id', 'ChallengeController.show')
3Route.put('/api/challenges/:id', 'ChallengeController.update')
4 .validator('UpdateChallenge')
5 .middleware(['auth'])
6Route.post('/api/challenges', 'ChallengeController.store')
7 .validator('CreateChallenge')
8 .middleware(['auth'])
9Route.delete('/api/challenges/:id', 'ChallengeController.destroy').middleware([
10 'auth'
11])
12
13Route.get('/api/me/challenges', 'MeController.challenges').middleware(['auth'])

Must be another way of doing this repetetive task ? And yes we can make use of grouping

1Route.group(() => {
2 Route.get('/', 'ChallengeController.all')
3 Route.get('/:id', 'ChallengeController.show')
4}).prefix('/api/challenges')
5Route.group(() => {
6 Route.post('/', 'ChallengeController.store').validator('CreateChallenge')
7 Route.put('/:id', 'ChallengeController.update').validator('UpdateChallenge')
8 Route.delete('/:id', 'ChallengeController.destroy')
9}).prefix('/api/challenges').middleware(['auth'])

If you ask why do we don't nested them, it's because right now we can't with the version we run. This is the error you will get

1RuntimeException: E_NESTED_ROUTE_GROUPS: Nested route groups are not allowed

I hope you enjoy this post :) And we talk in part 4 where we will start to add a bit more interaction with the API :)

Previous

Table driven test with jest

Next

Build a REST API with AdonisJs and TDD Part 2

Subscribe to the Newsletter

Receive notification when new article get posted

Comments