Build a REST API with AdonisJs and TDD Part 3
June 02, 2019 • 4 minutes to read
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'23const Factory = use('Factory')4const { test, trait } = use('Test/Suite')('Get User Challenges')56trait('Test/ApiClient')7trait('Auth/Client')89test('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)1415 await user.challenges().saveMany(challenges)16 await otherUser.challenges().saveMany(otherChallenges)1718 const response = await client19 .get('/api/me/challenges')20 .loginVia(user, 'jwt')21 .end()2223 response.assertStatus(200)2425 assert.equal(response.body.length, 2);2627 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() {23}
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();45 const challenges = await user.challenges().fetch();67 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'23const Factory = use('Factory')4const { test, trait } = use('Test/Suite')('Update Challenge')56trait('Test/ApiClient')7trait('Auth/Client')89test('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()1213 await user.challenges().save(challenge)1415 const data = {16 title: 'This is my new title'17 }1819 const response = await client20 .put(`/api/challenges/${challenge.id}`)21 .loginVia(user, 'jwt')22 .send(data)23 .end()2425 response.assertStatus(200)2627 response.assertJSONSubset({28 id: challenge.id,29 title: data.title30 })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'23class UpdateChallenge {4 get rules() {5 return {6 title: 'string',7 description: 'string'8 }9 }1011 get messages() {12 return {13 string: '{{ field }} is not a valid string'14 }15 }1617 get validateAll() {18 return true19 }2021 async fails(errorMessages) {22 return this.ctx.response.status(400).json(errorMessages)23 }24}2526module.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()34 const challenge = await Challenge.findOrFail(params.id)56 if (challenge.user_id !== user.id) {7 throw new UnauthorizedException();8 }910 challenge.merge(request.only(['title', 'description']));1112 await challenge.save();1314 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'23const { LogicalException } = require('@adonisjs/generic-exceptions')45class UnauthorizedException extends LogicalException {6 handle(error, { response }) {7 response.status(401).send('Not authorized')8 }9}1011module.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 client4}) => {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()89 await otherUser.challenges().save(challenge)1011 const data = {12 title: 'This is my new title'13 }1415 const response = await client16 .put(`/api/challenges/${challenge.id}`)17 .loginVia(user, 'jwt')18 .send(data)19 .end()2021 response.assertStatus(401)2223 const _challenge = await use('App/Models/Challenge').find(challenge.id)2425 // check if the title really didn't change26 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'23const Factory = use('Factory')4const { test, trait } = use('Test/Suite')('Delete Challenge')56trait('Test/ApiClient')7trait('Auth/Client')89test('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()1213 await user.challenges().save(challenge)1415 const response = await client16 .delete(`/api/challenges/${challenge.id}`)17 .loginVia(user, 'jwt')18 .end()1920 response.assertStatus(204)21})2223test('cannot delete challenge if not the author', async ({24 assert,25 client26}) => {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()3031 await otherUser.challenges().save(challenge)3233 const response = await client34 .delete(`/api/challenges/${challenge.id}`)35 .loginVia(user, 'jwt')36 .end()3738 response.assertStatus(401)3940 const _challenge = await use('App/Models/Challenge').find(challenge.id)4142 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
1Route2 .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()34 const challenge = await Challenge.findOrFail(params.id)56 if (challenge.user_id !== user.id) {7 throw new UnauthorizedException();8 }910 await challenge.delete()1112 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])1213Route.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 :)