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.
Route.group(() => { // posts Route.group(() => { Route.get('/', 'PostsController.index').as('index') Route.get('/:id', 'PostsController.show').as('show') }).prefix('/posts').as('posts') }).prefix('/:subAccountId')
Copied!
- start
- routes.ts
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.
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 }) } }
Copied!
- app
- Controllers
- Http
- PostsController.ts
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.
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 } }
Copied!
- providers
- AppProvider.ts
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.
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
Copied!
- start
- routes.ts
First, we removed the /:subAccountId
route parameter prefix.
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 }) }
Copied!
- providers
- AppProvider.ts
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.
declare module '@ioc:Adonis/Core/Route' { interface RouteGroupContract { withSubAccount(): this } }
Copied!
- contracts
- route.ts
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.
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
Copied!
- start
- routes.ts
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
.
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 }) }
Copied!
- providers
- AppProvider.ts
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'])
Copied!
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
.
import SubAccount from "App/Models/SubAccount"; declare module '@ioc:Adonis/Core/HttpContext' { interface HttpContextContract { subAccount: SubAccount } }
Copied!
- contracts
- httpContext.ts
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
.
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 }) } }
Copied!
- app
- Controllers
- Http
- PostsController.ts
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.
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 }) } }
Copied!
- app
- Controllers
- Http
- PostsController.ts
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.
Join The Discussion! (4 Comments)
Please sign in or sign up for free to join in on the dicussion.
AvantiC
Hi Tom,
great explanation!
Is there any benefit of doing this inside a Route-Macro compared to a regular middleware - except for the syntactic sugar when applying it to the routes?
Please sign in or sign up for free to reply
tomgobich
Hey - sorry I missed your comment here. Will add my response from your YouTube comment in case anyone else is wondering the same.
Yeah, a portion of the benefit is syntactical sugar but it does bind the route param definition to the middleware, so that the two become a packaged deal. In this example, it’s similar to route model binding, but for the context. Not a major benefit, but enough I felt it worth showing to maybe spark ideas for folks who might have a more pointed use-case.
Please sign in or sign up for free to reply
frp
In 6, it looks like HTTPContext is a type, not an interface, so we can't just extend it that way, right?
Please sign in or sign up for free to reply
tomgobich
Yep, you can still extend it using the same approach, the only difference is where the type's namespace is and that it's now called just
HttpContext
instead ofHttpContextContract
. The contracts directly also no longer exists, but you can add the below anywhere you'd like, including the same file you're extending theHttpContext
within :)Please sign in or sign up for free to reply