Playing Next Lesson In
seconds

Let's Learn AdonisJS 7 #2.3

Route Parameters & Matchers

In This Lesson

Learn AdonisJS route parameters and matchers. Capture dynamic segments in URLs and validate and cast with route matchers.

Created by
@tomgobich
Published

Now, when it comes time to show details about a specific competition, we don't want to define a route for each individual competition. Instead, we want to allow the route to accept a dynamic identifier for the competition we're after. That's where route parameters come into play. They allow us to specify one or more portions of a route's pattern as dynamic under the pattern name we provide.

For example, if we want to identify a competition by its id, we can define the pattern as /competitions/:id. Route parameters are designated by a colon (:), and the term that follows is the parameter's name, the property with which we can access the dynamic value.

router.get('/challenge/:id', async (ctx) => {
  const challengeId = ctx.params.id
  return challengeId
})
Copied!
  • start
  • routes.ts

Our HttpContext provides a params object containing parsed route parameters. Since we've named our param id, that is the property name we can access the value with. Up til now, we've rendered an HTML page using EdgeJS. For now, let's just simply return this ID to see what we get!

When we request this with our browser, we get back a plaintext response. By default, AdonisJS will utilize content negotiation to determine the appropriate content type to respond with using the Accept header. If we instead specify this specifically as a JSON response, using the response on our HttpContext, we'll see our browser switches to a JSON viewer to match the new content-type of our response.

router.get('/challenge/:id', async (ctx) => {
  const challengeId = ctx.params.id
  return ctx.response.json({ id: challengeId })
})
Copied!
  • start
  • routes.ts

Route Machers/Validators

If you noticed, our value is coming back as a string. Meaning, if we were to move our challenges array out where this route can utilize it as well, we would need to convert the type of the parameter in order to find a 1:1 strict-equality match.

import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

router.on('/').render('pages/home').as('home')

router.on('/terms').render('pages/terms')

router.get('/challenges', async (ctx) => {
  return ctx.view.render('pages/challenges/index', { challenges })
})

router.get('/challenge/:id', async (ctx) => {
  const challenge = challenges.find((row) => row.id === Number(ctx.params.id))
  return ctx.response.json({ challenge })
})
Copied!
  • start
  • routes.ts

Instead of doing this, we can utilize route parameter matches and validation to:

  1. Ensure the ID is a valid number for this route pattern to actually be matched against

  2. Cast the ID route parameter to a number within our params object.

router.get('/challenge/:id', async (ctx) => {
  const challenge = challenges.find((row) => row.id === Number(ctx.params.id))
  return ctx.response.json({ challenge })
}).where('id', /^[0-9]+$/)
Copied!
  • start
  • routes.ts

Here, we're using regex to merely ensure the id route parameter is a number. If it isn't, the route won't be matched, and currently, that would result in us getting a 404 Not Found exception.

If we wanted to cast it to a number, we could add a cast to the validation.

router
  .get('/challenge/:id', async (ctx) => {
    const challenge = challenges.find((row) => row.id === ctx.params.id)
    return ctx.response.json({ challenge })
  })
  .where('id', { 
    match: /^[0-9]+$/,
    cast: (value) => Number(value)
  })
Copied!
  • start
  • routes.ts

Now, we're both ensuring it's a number and casting it from a string value to a number value. So our ctx.params.id is actually a number now, meaning we can get rid of the cast there. This is a common scenario, and AdonisJS knows that, so there is a convenient matcher we can use to perform this exact flow in a simplified manner.

router.get('/challenge/:id', async (ctx) => {
  const challenge = challenges.find((row) => row.id === ctx.params.id)
  return ctx.response.json({ challenge })
}).where('id', router.matchers.number())
Copied!
  • start
  • routes.ts

You'll notice there is also a matcher for slug and UUID, doing similar things there. If, like our IDs, you're always going to want a route parameter to be cast or validated, we can apply this globally across all our routes.

import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

router.where('id', router.matchers.number())

router.on('/').render('pages/home').as('home')

router.on('/terms').render('pages/terms')

router.get('/challenges', async (ctx) => {
  return ctx.view.render('pages/challenges/index', { challenges })
})

router.get('/challenge/:id', async (ctx) => {
  const challenge = challenges.find((row) => row.id === ctx.params.id)
  return ctx.response.json({ challenge })
})
Copied!
  • start
  • routes.ts

Now, anytime we use the name id for a route parameter, our number matcher will be used to validate and cast it.

Okay, finally, let's rig this up to a page!

node ace make:view pages/challenges/show
Copied!
@layout()

  <div>
    <h1>{{ challenge.text }}</h1>
    <p>Points: {{ challenge.points }}</p>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • show.edge

Then, we need to render the page instead of returning JSON as well. We can also spread specific properties out of our HttpContext as well to simplify our code a little as well.

router.get('/challenge/:id', async ({ view, params }) => {
  const challenge = challenges.find((row) => row.id === params.id)
  return view.render('pages/challenges/show', { challenge })
})
Copied!
  • start
  • routes.ts

We can now also link to this page from our /challenges page using an anchor element.

@let(availablePoints = challenges.reduce((total, challenge) => total + challenge.points, 0))

@if (completedChallenges?.length)
  @let(completedPoints = completedChallenges.reduce((total, challenge) => total + challenge.points, 0))
  @assign(availablePoints = availablePoints - completedPoints)
@endif

@layout()

  <div>
    <h1>Challenges</h1>
    <p>Welcome to the challenges page.</p>

    <div>
      Total Points Available: {{ availablePoints }}
    </div>

    @if (!challenges.length)
      <p>No challenges available.</p>
    @else
      <p>Below are our available challenges:</p>
    @endif

    <ul>
      @each(challenge in challenges)
        <li>
          <a href="/challenges/{{ challenge.id }}">
            <h3>{{ challenge.text }}</h3>
            <p>Points: {{ challenge.points }}</p>
          </a>
        </li>
      @endeach
    </ul>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

Our interpolation here will inject the challenge ID we're currently looping over into the href path to form our completed URL. We'll expand on this later on with a more sustainable linking approach.

Join the Discussion 2 comments

Create a free account to join in on the discussion
  1. @cgregoire

    Hey Tom, thanks for this new series, it's helping a lot !

    I am not sure if it was the goal but I found an "issue" with :
    router.where('id', router.matchers.number())

    if set globaly, this could lead to issues, imagine asking an external API with the key :id; which could be an objectId, or stringId or uuid ? this API id should named with something else than :id, I agree but for science, I tried my self, and the global (declared after the chained one) override the chained where clause.

    I understand for the purpose of the tutorial, it was just a friendly reminder, I appreciate the work and time you spend to bring us the knowledge !

    last thing, the <ul> with the each loop could have been in the @else of the !challenges.length (to avoid empty <ul>)

    1
    1. Responding to cgregoire
      @tomgobich

      Hey @cgregoire! Glad to hear it's helping!

      I wouldn't really call that an issue, per say, but you're correct. Like many things, just because it's a solution doesn't mean it's a good solution for all use-cases. If your application is going to need a combination of UUID, int, etc IDs then a blanket matcher for all IDs isn't a good option. If, however, your IDs are all normalized to one type, like integer values, then it will fit the bill just fine!

      Good call on the ul, that could definitely go within the else to prevent the empty ul from being added!

      Thank you for taking the time to provide feedback!! I appreciate it!

      1