Extending the Adonis Router and Route Matchers

In this lesson, we'll be learning how we can add our own chainable methods using macros to the Adonis Router's Route, Route Group, Route Resource, Brisk Route, and Matchers properties.

Published
Oct 20, 21
Duration
15m 44s

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

Today we're going to be walking through various ways we can extend the Route module within Adonis. We'll specifically be adding additional chainable methods to the Route object, our group chain, our resource chain, and our brisk route chain. Lastly, we'll learn how we can add our own matcher onto the Route.matchers object.

Where To Extend

Before we can extend any of these objects, we must first learn where we can extend them since things can go awry if we add them too soon or too late within the Adonis lifecycle.

In most cases, when we extend things we'll want to do so within a provider since these have hooks we can use for particular lifecycle events. In this particular case, we'll want to extend our route objects just after the IoC container is ready to use, so the correct lifecycle event for us today is the boot hook.

So, head into your AppProvider at /providers/AppProvider.ts and you'll be greeted with the below.

// providers/AppProvider.ts

import { ApplicationContract } from '@ioc:Adonis/Core/Application'

export default class AppProvider {
  constructor (protected app: ApplicationContract) {
  }

  public register () {
    // Register your own bindings
  }

  public async boot () {
    // IoC container is ready
  }

  public async ready () {
    // App is ready
  }

  public async shutdown () {
    // Cleanup, since app is going down
  }
}

First things first, let's get our Route module imported so we can extend it. Now, we're not going to want to import it at the top level, like our ApplicationContract is, because we need to wait for the IoC container to be ready so that everything is bound and ready to go on our Route module. So, we'll want to import our Route module within our boot method.

To make importing within our boot method easier we can make use of our app's IoC container, accessible at this.app.container . This object contains a method called use that we can use to import using the Route modules binding lookup, `Adonis/Core/Route`.

public async boot () {
  // IoC container is ready
  const Route = this.app.container.use('Adonis/Core/Route')
}

With that, we're ready to move forward and start extending!

Macroable Constructor Properties

Within our Route module, Adonis provides macroable versions of certain properties. It has one for our Route, RouteGroup, RouteResource, BriskRoute, and RouteMatchers. Each of these contains three methods getter, hydrate, and macro. That's the structure of the MacroableConstructor.

Macro is what we'll be focusing on today. It's what's going to allow us to easily add additional chainable methods to our Route module. The first argument is the name of the macro function that'll be registered to the Route module. Below is how we can use it.

// macro definition
Route.Route.macro('macroName', function (args) {
  // do stuff
  
  // allow macro to be chained further
  return this
})

// macro usage
Route.get('posts', () => 'post handler').macroName()

One very important note to make here is that you want a scoped function, so don't use an arrow function. The macro callback will be given the scope of the route chain, so in order to allow our macro to be chained off of further, we'll want to return this to continue the scope down the chain. Also, note you can pass arguments into your macro function and they'll be passed to your macro callback where I have args above.

Getter is very similar, though, I can't think of any use-cases where it'd be useful with our Route module. The only difference here would be we'd return back whatever it is we're trying to get and instead of calling as a method it's just a property.

// getter definition
Route.Route.getter('getterName', function () {
  return 'your data'
})

// getter usage
Route.getterName

Hydrate is a function we can call to remove dynamically added methods and properties, so if we need to remove a macro after we're done using it we can call hydrate to make that happen.

Route.Route.hydrate()

Extending the Route & RouteContract

To start with we're going to run through the example Adonis has on their documentation because it aligns really well with where we left off in the last lesson with Signed URLs. We'll be extending the Route module to include an additional chain called mustBeSigned that will add a middleware to verify requests for the route contain a valid Signed URL signature.

Get AppProvider Up-To-Date

So, let's get our AppProvider up-to-date with what we've covered already.

// providers/AppProvider.ts

import { ApplicationContract } from '@ioc:Adonis/Core/Application'

export default class AppProvider {
  constructor (protected app: ApplicationContract) {
  }

  public register () {
    // Register your own bindings
  }

  public async boot () {
    // IoC container is ready
    const Route = this.app.container.use('App/Core/Route')

    Route.Route.macro('mustBeSigned', function () {
      // do stuff

      return this
    })
  }


  public async ready () {
    // App is ready
  }

  public async shutdown () {
    // Cleanup, since app is going down
  }
}

Define MustBeSigned Macro

Next, we're going to want to define a middleware to run for the route when we chain this macro to the route. This middleware will be in charge of verifying the route has a valid Signed URL signature on it. To keep things brief, I'm going to focus on the boot method in our AppProvider from now on.

// providers/AppProvider.ts > boot()

public async boot () {
  // IoC container is ready
  const Route = this.app.container.use('App/Core/Route')

  Route.Route.macro('mustBeSigned', function () {
    this.middleware(async (ctx, next) => {
      if (!ctx.request.hasValidSignature()) {
        ctx.response.badRequest('Invalid signature')
        return
      }

      await next()
    })

    return this
  }
}

Here we're using the scope of our Route chain to self-define a middleware to the route that calls the hasValidSignature method on our HttpContext's request object to verify that the route has a valid signature. If it does we'll await next, otherwise, we'll return back that the request has an invalid signature.

Inform TypeScript by Extending RouteContract

Lastly, before we can use our fancy new macro, we need to inform TypeScript about it. To do this, let's create a new file within our contracts directory called route.ts . Within this file, we'll want to declare the module @ioc:Adonis/Core/Route and extend the interface RouteContract with our macro method.

// contracts/route.ts

declare module '@ioc:Adonis/Core/Route' {
  interface RouteContract {
    mustBeSigned(): this
  }
}

Using Your New Macro

The last thing to do is to put it to use! So, on the reset password route, we defined in the last lesson, we can now refactor it to the below, making use of our mustBeSigned macro to verify the signature is valid on the route. We'll know, thanks to the middleware we defined in our macro, that if we're inside the route handler, that the signature is indeed valid.

// start/routes.ts

Route.get('/reset-password/:email', async ({}) => {
  return 'signature is valid'
}).as('reset.password').mustBeSigned()

Extending Route Group & GroupContract

Next, let's go ahead and get extending the route groups out of the way. We'll breeze through this one since it's the exact same as the route extension.

First We Define the Macro

Instead of defining our macro on Route.Route, we’ll instead want to use Route.RouteGroup. Everything else remains the same.

// providers/AppProvider.ts > boot()

public async boot () {
  // IoC container is ready
  const Route = this.app.container.use('App/Core/Route')

  // ... route macro

  Route.RouteGroup.macro('mustBeSigned', function () {
    this.middleware(async (ctx, next) => {
      if (!ctx.request.hasValidSignature()) {
        ctx.response.badRequest('Invalid signature')
        return
      }

      await next()
    })

    return this
  })
}

Then We Inform TypeScript

Again, instead of extending the RouteContract interface we’ll extend the RouteGroupContract interface.

// contracts/route.ts

declare module '@ioc:Adonis/Core/Route' {
  // ... other interfaces

  interface RouteGroupContract {
    mustBeSigned(): this
  }
}

Last We Put It To Use

Since we've added mustBeSigned to our route groups as well, we can now verify that all requests to routes inside the group have a valid signature on them.

// start/routes.ts

Route.group(() => {
  Route.get('/reset-password/:email', async ({}) => {
    return 'signature is valid'
  }).as('reset.password')
}).mustBeSigned()

Extending Brisk Routes & BriskRouteContract

I don't have a very good use-case for why you'd need to extend a brisk route, but let's cover it in case you do. Most things behave the same way as the route and route group, however, they do have a limited scope so there's no access to middleware or, really, anything the other routes have. Which, that's of course why they're brisk routes. So, let's just create a utility method to redirect to a static route.

// providers/AppProvider.ts > boot()

public async boot () {
  // IoC container is ready
  const Route = this.app.container.use('App/Core/Route')

  // ... other macros

  Route.BriskRoute.macro('goHome', function() {
    this.redirect('/')
  })
}
// contracts/route.ts

declare module '@ioc:Adonis/Core/Route' {
  // ... other interfaces

  interface BriskRouteContract {
    goHome(): this
  }
}
// start/routes.ts

Route.on('/testing').goHome()

So, now if we visit /testing in our application our goHome macro will redirect us back to the home page.

Extending Route Resource & RouteResourceContract

Now, we haven't covered resource routes yet because they use Controllers, which we haven't covered yet. So, we'll be covering resource routes while we're covering Controllers. However, I do want to go ahead and cover extending the resource route chain here.

If you're not familiar with resource routes, they register all routes needed to show pages and perform CRUD operations on a given resource. So, they're defining multiple routes all in one call. Say we were working with posts, a post resource route would define routes for

  • Getting all posts

  • Getting a single post

  • Showing the create page

  • Storing the post

  • Showing the update page

  • Updating the post

  • Deleting the pos

So, with that in mind, we can't define middleware the same way we are with the others because we need granular control over which route(s) the middleware should apply to. To aid with this, we'll provide a function to the middleware call and we can define a particular key for the route we want to add the middleware to. Alternatively, you can also define the key as '*' to define the middleware for all routes in the resource.

// providers/AppProvider.ts > boot()

public async boot () {
  // IoC container is ready
  const Route = this.app.container.use('App/Core/Route')

  // ... other macros

  Route.RouteResource.macro('mustBeAdmin', function () {
    this.middleware({
      // perform on specific route
      destroy: async (ctx, next) => {
        // perform some check to ensure record can be deleted
        if (ctx.auth.user.roleId !== 1) {
          ctx.response.unauthorized("You don't have the needed permission")
        }
        await next()
      },
      // perform on all resource routes
      '*': async (ctx, next) => {
        await next()
      }
    })

    return this
  })
}
// contracts/route.ts

declare module '@ioc:Adonis/Core/Route' {
  // ... other interface extensions

  interface RouteResourceContract {
    mustBeAdmin(): this
  }
}
// start/routes.ts

Route.resource('posts', 'PostsController').mustBeAdmin()

Extending Route Matchers & RouteMatchersContract

Lastly, let's cover how we can add our own matcher to the available Route.matchers . Unlike the others, we'll want to return back a valid matcher object instead of returning back the scope. Remember, a matcher object consists of a match key with a RegExp value and a cast key with a function that receives the value and needs to return the casted value.

// providers/AppProvider.ts > boot()

public async boot () {
  // IoC container is ready
  const Route = this.app.container.use('App/Core/Route')

  // ... other macros

  Route.RouteMatchers.macro('alphaString', function () {
    return {
      match: /^[a-z]+$/i,
      cast: (value) => value.toString()
    }
  })
}

Note I have the cast above to show how you can include it, but since all route parameters come through as strings there's no need for it in this use case.

// contracts/route.ts

declare module '@ioc:Adonis/Core/Route' {
  // ... other interface extensions

  interface RouteMatchersContract {
    alphaString(): { match: RegExp, cast: (value: string) => string }
  }
}
// start/routes.ts

Route.get('/test/:testing').where('testing', Route.matchers.alphaString())

Next Up

So, we've covered how you can add your own route matcher, and add additional chainable methods on your route, route group, route resource, and brisk route objects. In the next lesson, we'll start cleaning up our route files by extracting our route definitions out into controllers.

Join The Discussion! (0 Comments)

Please sign in or sign up for free to join in on the dicussion.

robot comment bubble

Be the first to Comment!