TDD course with AdonisJs - 8. Third party APIs, ioc and custom validators

Published 11/7/2019

This time let's try something completely different. Let's see how we can implement a third party API.

As always you can find all the changes in the following commit: https://github.com/MZanggl/tdd-adonisjs/commit/358466cbbc86f49f3343378dea2500ce87b05002

For the API I chose http://www.purgomalum.com/ to check against profanities. This API doesn't require an API key and is therefore perfect for this example.

We can check for profanities by accessing this URL: https://www.purgomalum.com/service/containsprofanity?text=jackass It simply returns a boolean whether it contains profanities or not.

First let's add the test to our functional "thread.spec.js" tests

test('user can not create thread where title contains profanities', async ({ client }) => {
  const user = await Factory.model('App/Models/User').create()
  const attributes = { title: 'jackass', body: 'body' }
  const response = await client.post('/threads').loginVia(user).send(attributes).end()
  response.assertStatus(400)
})

This test will fail since it still returns 200. So let's fix that.

To access the API we will use the node-fetch library.

npm install node-fetch

Check out my e-book!

Learn to simplify day-to-day code and the balance between over- and under-engineering.

And to test it, let's add add the profanity check to the ThreadController's store method.

const fetch = require('node-fetch')

//...

async store({ request, auth, response }) {
    const containsProfanity = await fetch('https://www.purgomalum.com/service/containsprofanity?text=' + request.input('title')).then(r => r.text())
    if (containsProfanity === 'true') {
        return response.status(400).json({})
    }

// ...

This works, but there are a couple things wrong with this.

  1. Performing the actual fetch inside the test will slow our tests down for nothing. This would also be problematic if there is an API limit.
  2. The controller is not the best place to keep this logic. A custom validation would make more sense.

Let's look at problem 2 first.

First let's revert the controller back to its original state.

Next, add the profanity check to the StoreThread validator.

'use strict'

class StoreThread {
  get rules () {
    return {
      title: 'required|profanity', 
      body: 'required'
    }
  }
}

module.exports = StoreThread

This will fail since we first have to add a 'profanity' rule to Adonis.

To add new rules we can directly hook into Adonis to extend the validator class. For this, we first have to create a file hooks.js inside the start folder.

Then paste in the following code:

// start/hooks.js

const { hooks } = require('@adonisjs/ignitor')
const fetch = require('node-fetch')

hooks.after.providersRegistered(() => {
    use('Validator').extend('profanity', async (data, field, message) => {
        const value = data[field]
        // requried rule will take care of this
        if (!value) {
          return
        }
      
        const containsProfanity = await fetch('https://www.purgomalum.com/service/containsprofanity?text=' + value).then(r => r.text())
        if (containsProfanity === 'true') {
          throw message
        }
    })
})

Let's go through this!

  1. The callback passed to after.providersRegistered is, like it says, being executed after all providers (e.g. "Validator") were registered.
  2. Once the providers are registerd, we can access the validator with use('Validator').
  3. Validator provides an extend method with which we can create custom rules.
  4. The callback receives a couple of arguments. "data" contains the entire request data, "field" is the field which is validated against (in this case "subject") and "message" is the error message to throw when the validation fails (This can be overwritten inside our StoreThread validator, that's why it is being passed as a variable here).
  5. The rest of the code is very similar to before.

Now we made the tests pass again. But we significantly lowered the speed of our tests since we always call a rest API.

To overcome this, let's fake the implementation. For this, we first have to move out the core logic of the profanity check into its own service.

// app/Services/ProfanityGuard.js

'use strict'

const fetch = require('node-fetch')

class ProfanityGuard {
    async handle(value) {      
        const response = await fetch('https://www.purgomalum.com/service/containsprofanity?text=' + value)
        return (await response.text()) === 'false'
    }
}

module.exports = ProfanityGuard

and our hooks.js simply becomes

const { hooks } = require('@adonisjs/ignitor')

hooks.after.providersRegistered(() => {
    use('Validator').extend('profanity', async (data, field, message) => {
        const profanityGuard = ioc.make('App/Services/ProfanityGuard')
        if (!data[field]) return
      
        const isClean = await profanityGuard.handle(value)
        if (!isClean) throw message
    })
})

This might look like we simply moved the file out, but because we now do ioc.make('App/Services/ProfanityGuard') we can actually fake this part of the code. So, I think I have to explain ioc.make('App/Services/ProfanityGuard') here...

In case you didn't know, the global use function we always use is just a shorthand for ioc.use, so it is being resolved out of the service container. ioc.make is essentially just a handy method to do "new use(...)". Since the file is inside the "app" folder and this folder is autoloaded, we can access every file within without having to register it to the container. If you are unfamiliar with these terms, check out my blog post on the topic or the Adonisjs documentation. Basically, since we now resolve the dependency out of the service container, we can also fake its implementation!

To do so, let's go to our functional thread.spec.js file and add the following imports to the top:

const { test, trait, before, after } = use('Test/Suite')('Thread') // "before" and "after" are new
const { ioc } = use('@adonisjs/fold')

Next, add the fakes as the first thing after registering all the traits:


before(() => {
  ioc.fake('App/Services/ProfanityGuard', () => {
    return {
      handle: value => value !== 'jackass'
    }
  })
})

after(() => {
  ioc.restore('App/Services/ProfanityGuard')
})

So the ProfanityGuard will now simply validate the input against the word "jackass", there is no more fetching involved.

And our tests still pass!

A couple things to note here though is that we no longer test the profanity service. In fact we faked the entire service so we have 0 test coverage on that. This is fine for the functional test. To test the service specifically we can drop down to a unit test. In that we would only fake the "node-fetch" implementation.

You can create the test using

adonis make:test ProfanityGuard

and then choose unit. This is the content of our test:

'use strict'

const { test, trait, before, after } = use('Test/Suite')('ProfanityGuard')
const { ioc } = use('@adonisjs/fold')
const ProfanityGuard = use('App/Services/ProfanityGuard')

before(() => {
  ioc.fake('node-fetch', () => {
    return async () => ({
      text: async value => {
        return (value === 'jackass').toString()
      }
    })
  })
})

after(() => {
  ioc.restore('node-fetch')
})


test('can verify that passed value is a profanity', async ({ assert }) => {
  const profanityGuard = new ProfanityGuard()
  assert.isTrue(await profanityGuard.handle('jackass'))
})

test('can verify that passed value is not a profanity', async ({ assert }) => {
  const profanityGuard = new ProfanityGuard()
  assert.isTrue(await profanityGuard.handle('test'))
})

We now fake the fetch implementation, but it's not yet working since we are still using "require" in our ProfanityGuard. Luckily, the "use" method can also resolve node_module dependencies. So let's fix it:

'use strict'

class ProfanityGuard {
    constructor() {
        this.fetch = use('node-fetch')
    }
    async handle(value) {
        const response = await this.fetch('https://www.purgomalum.com/service/containsprofanity?text=' + value)
        return (await response.text()) === 'false'
    }
}

module.exports = ProfanityGuard

We not only switched out "require" with "use", but also moved it to the constructor since it can't be faked if it is at the top (since it gets required before we register the fake).

There is no real need to test the "fetch" library or the actual rest API since they are (hopefully) already tested by those is in charge of them.

That's all there is for this episode. Let me know in the comments if there is something you'd like to see in a future episode!