How To Add A Global Property To Your HttpContext in AdonisJS 5

tomgobich avatar
tomgobich
9 min
Quick Summary

In this lesson, we'll be covering how we can extend our HttpContext with a new property that we'll populate using a route parameter.

I thought it might be beneficial to go through a real-world scenario where we need to extend our HttpContext with a global property specific to each request while also adding a custom macro to our Route Group.

So, for this lesson, I have an application in its early stages, you can find the starting repository here. We have three models, User, SubAccount, and Post. A user can have many sub-accounts and a sub-account can have many users, so this is a many-to-many relationship. Then, our sub-account has many posts, but our posts belong to a specific sub-account. So this is a one-to-many relationship.

Overall, here's the schema of our table ids:

users
  id

sub_accounts
  id

sub_account_users
  id
  user_id
  sub_account_id

posts
  id
  sub_account_id

Defining Requirements

To start, we have within our routes.ts file currently two routes, one to show all posts within a sub-account, and one to view a specific post within a sub-account.

// start/routes.ts

Route.group(() => {
  
  // posts
  Route.group(() => {
    
    Route.get('/', 'PostsController.index').as('index')
    Route.get('/:id', 'PostsController.show').as('show')

  }).prefix('/posts').as('posts')

}).prefix('/:subAccountId')

Our routes are structured this way so that we can strictly scope posts to the requested subAccountId. So, if we requested /2/posts we should only get posts that have a sub_account_id of 2. If we request /2/posts/3 we should get details on the post with an id of 3 only if that post belongs to sub-account 2. If it doesn't a 404 should be thrown.

In terms of our controller, we're going to need to query for both our sub-account and our post(s). So, to start with we have the following controller.

// app/Controllers/Http/PostsController.ts

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import SubAccount from 'App/Models/SubAccount'

export default class PostsController {
  public async index ({ response, params }: HttpContextContract) {
    const subAccount = await SubAccount.findOrFail(params.subAccountId)
    const posts = await subAccount.related('posts').query().orderBy('created_at', 'desc')

    return response.json({ posts, subAccount })
  }

  public async show ({ response, params }: HttpContextContract) {
    const subAccount = await SubAccount.findOrFail(params.subAccountId)
    const post = await subAccount.related('posts').query().where('id', params.id).firstOrFail()

    return response.json({ post, subAccount })
  }
}

As our application sits right now, we'd need to have a query for our sub-account in each route handler so that we could return that data back with our response.

Our goal is to extract everything related to the sub-account out into a single macro on our route group. So, our macro should define the subAccountId route parameter on the group as well as populate the requested subAccount record. So, let's use what we learned in the routing module of our Let's Learn Adonis 5 series to make this happen.

Defining the Route Group Macro

First, let's define our macro on our route group. So, within providers/AppProvider.ts within the boot method, let's import our Route module and define our macro.

Remember, we need to import modules using the IoC Container within the boot method because prior to the boot method, the bindings are still being registered.

// 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('Adonis/Core/Route')

    Route.RouteGroup.macro('withSubAccount', function () {
      return this
    })
  }

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

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

Here we've imported our Route module, using our app's container property, from Adonis/Core/Route. Then, on the RouteGroup property of our Route module, we're defining the macro withSubAccount.

Remember, by defining the macro on Route.RouteGroup, the macro will be assigned and made available within the chain options on Route.group(() => {}).

Move Route Parameter Into Route Group Macro

Next, we'll extract the .prefix('/:subAccountId') off our root route group within our routes.ts file and instead, define it within our withSubAccount macro so that everything sub-account-related is contained within this block of code.

// start/routes.ts

Route.group(() => {
  
  // posts
  Route.group(() => {
    
    Route.get('/', 'PostsController.index').as('index')
    Route.get('/:id', 'PostsController.show').as('show')

  }).prefix('/posts').as('posts')

}) // <-- remove prefix here

First, we removed the /:subAccountId route parameter prefix.

// providers/AppProvider.ts > boot()

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

  Route.RouteGroup.macro('withSubAccount', function () {
    this.prefix('/:subAccountId') // <-- move to macro

    return this
  })
}

Then, we added it within our withSubAccount macro. Once we apply our withSubAccount macro to our route group, this will change nothing structurally about our routes, it's just to keep everything sub-account related contained to this macro. Remember to return this at the end of your macro so that you can chain additional route group methods off your withSubAccount macro.

Inform TypeScript About Our Route Group Macro

Since we've added a new macro to our route group, we'll need to inform TypeScript about this method by merging it onto the RouteGroupContract interface. To do this, we can create a new file within our contracts directory called route.ts and include the following.

// contracts/route.ts

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

Here we're declaring the module @ioc:Adonis/Core/Route so that we can merge its inner interfaces and types. Here we specifically only need to merge our withSubAccount macro into the RouteGroupContract type which we can do by defining the interface RouteGroupContract within this module and adding our new method withSubAccount. Since we return the context of the group, we'll also specify that it returns this.

Applying Our Route Group Macro

Now that TypeScript is aware of our withSubAccount macro, we can go ahead and add it onto our route group where we previously had our subAccountId route parameter prefix.

// start/routes.ts

Route.group(() => {
  
  // posts
  Route.group(() => {
    
    Route.get('/', 'PostsController.index').as('index')
    Route.get('/:id', 'PostsController.show').as('show')

  }).prefix('/posts').as('posts')

}).withSubAccount() // <-- add withSubAccount() here

Adding Middleware To Route Group Macro

Next, we'll define a middleware within our withSubAccount macro that will query the requested sub-account record and place it on our request's HttpContext. If a sub-account cannot be found for the requested subAccountId, we'll throw a 404 by using findOrFail.

// providers/AppProvider.ts > boot()

public async boot () {
  // IoC container is ready
  const Route = this.app.container.use('Adonis/Core/Route')
  const SubAccount = (await import('App/Models/SubAccount')).default

  Route.RouteGroup.macro('withSubAccount', function () {
    this.prefix('/:subAccountId')
    this.middleware(async (ctx, next) => {
      // query for sub-account
      const subAccount = await SubAccount.findOrFail(ctx.params.subAccountId)

      // place sub-account record on HttpContext
      ctx.subAccount = subAccount

      await next()
    })

    return this
  })
}

First, since our SubAccount model file contains an import using the IoC Container, we'll import it within our boot method instead of at the top level of our AppProvider.

Next, we'll define our middleware within our withSubAccount macro. This middleware will then run on every request that's within the route group containing our withSubAccount macro.

Lastly, inside the middleware we'll use the requested subAccountId, which we can grab off our HttpContext params property, to query for the sub-account record. Then, place that record directly onto the HttpContext, just like a normal object. With this, subAccount is now accessible directly off our HttpContext object as ctx.subAccount.

We can then use ctx.subAccount within our route handlers/controller methods, services, validators, and even other middleware so long as they're defined after our withSubAccount chain. For example:

Route.group(() => {})
  .withSubAccount()
  .middleware(['otherMiddleware'])

Inform TypeScript About New HttpContext Property

Then, since we're adding a new property to our HttpContext, we need to inform TypeScript about this change. So, within our contracts directly, let's create another new file called httpContext.ts.

// contracts/httpContext.ts

import SubAccount from "App/Models/SubAccount";

declare module '@ioc:Adonis/Core/HttpContext' {
  interface HttpContextContract {
    subAccount: SubAccount
  }
}

First, we import our SubAccount model so we can use it to define the type of our new HttpContext property. Then, we declare the module @ioc:Adonis/Core/HttpContext so that we can merge its inner interfaces and types. Here we specifically want to merge into the HttpContextContract interface by defining our new subAccount property as the type SubAccount.

You can make your subAccount nullable if not all requests are going to use withSubAccount.

Use SubAccount Off HttpContext

All that's left now to do is to replace our sub-account queries in our controller(s) and instead use the subAccount property directly off our HttpContext.

// app/Controllers/Http/PostsController.ts

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'

export default class PostsController {
  public async index ({ response, subAccount }: HttpContextContract) {
    const posts = await subAccount.related('posts').query().orderBy('created_at', 'desc')

    return response.json({ posts, subAccount })
  }

  public async show ({ response, params, subAccount }: HttpContextContract) {
    const post = await subAccount.related('posts').query().where('id', params.id).firstOrFail()

    return response.json({ post, subAccount })
  }
}

So here, instead of querying for our subAccount record within each method, we can now extract the subAccount value straight out of our HttpContext since our middleware within withSubAccount has populated it directly onto our HttpContext.

If you're interested, you can view the repository with all steps of this lesson completed, here.

Reminders

A few things to keep in mind with this approach. First, our sub-account is going to be queried for each request using a route within the group containing our withSubAccount macro. If you have a route defined within this group that doesn't need the sub-account, the sub-account will still be queried. So, be mindful of what you place within the group to prevent unneeded queries.

Second, for this use case, I specifically need subAccount data to be within my response. If I didn't I could've easily remove the subAccount query altogether by just using the subAccountId parameter within a where statement on the post queries. For example, my controller could've looked like the below.

// app/Controllers/Http/PostsController.ts

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Post from 'App/Models/Post'

export default class PostsController {
  public async index ({ response, params }: HttpContextContract) {
    const posts = await Post.query()
      .where('subAccountId', params.subAccountId)
      .orderBy('created_at', 'desc')

    return response.json({ posts })
  }

  public async show ({ response, params }: HttpContextContract) {
    const post = await Post.query()
      .where('subAccountId', params.subAccountId)
      .where('id', params.id)
      .firstOrFail()

    return response.json({ post })
  }
}

Third, there are going to be use cases where you need to perform more specific queries on your sub-account. Or, maybe you need to load relationships onto your sub-account. In those cases, consider whether it'd be more beneficial to exclude those routes from the group containing withSubAccount on it so you can specifically query for your sub-account within those handlers.

Summary

In this lesson, we've covered how you can extract a route parameter from a route group into a route group macro. We then learned how we can use that route parameter to populate the actual record onto our HttpContext using a middleware within our route group macro. In essence, scoping everything related to the parameter to our macro. We also learned how we can inform TypeScript about the macro addition to our RouteGroupContext and our HttpContextContract. Lastly, we discussed a few things to keep in mind with this approach.