Let's Learn Adonis 5 Routing Lesson 2.1

Dynamic Routing with Route Parameters

tomgobich avatar
tomgobich
9 min
Quick Summary

In this lesson, we go in-depth with route parameters covering optional route params, validation using matchers, casting, wildcards, and a few things to watch out for.

Route parameters give us a way to add dynamic routes to our route definitions so that we don't have to define a route for each identity that's within our database.

For example, without route parameters, in order to have a route for posts with an id of 1, 2, and 3, we'd need to manually define the same route for each individual id. That quickly becomes a scalability issue, as you can see below.

Route.get('/posts/1', () => 'get post 1')
Route.get('/posts/2', () => 'get post 2')
Route.get('/posts/3', () => 'get post 3')

Route.put('/posts/1', () => 'get post 1')
Route.put('/posts/2', () => 'get post 2')
Route.put('/posts/3', () => 'get post 3')

Route.delete('/posts/1', () => 'get post 1')
Route.delete('/posts/2', () => 'get post 2')
Route.delete('/posts/3', () => 'get post 3')

With route parameters, we can make the id portion of the URL dynamic so that we only need to define the route once.

Defining A Route Parameter

Route parameters in Adonis start with a colon (:), followed by whatever you want to name that parameter. Typically you'd name the route parameter after what value it should hold.

Route.get('/posts/:id', () => 'get post with provided id')
Route.put('/posts/:id', () => 'update post with provided id')
Route.delete('/posts/:id', () => 'delete post with provided id')

Here, we've named our route parameter id to signify that the route parameter is meant to be a post id value. So regardless if we send a GET request for http://localhost:3333/posts/1, http://localhost:3333/posts/2, or http://localhost:3333/posts/3 our GET route definition will be used.

Of course, be mindful of this because our :id route parameter will match anything that's passed into that position in the url. For example, http://localhost:3333/posts/not-an-id.

Accessing A Route Parameter Value

At this point, you might be asking, "how do I, as the developer, know what the user is passing through as the id for the route?". If you recall back to the HttpContext lesson, we actually have a property called params within our HttpContext. This params property is how we can access any and all route parameters a user is providing for a given route.

Route.get('/posts/:id', ({ params }) => {
  return `get post with id of ${params.id}`
})

Route.put('/posts/:id', ({ params }) => {
  return `update post with id of ${params.id}`
})

Route.delete('/posts/:id', ({ params }) => {
  return `delete post with id of ${params.id}`
})

Since we've named our route parameter id, it's value will be added to our params object as id. So, we can access the route parameter value via params.id.

Route.get('/posts/:identity', ({ params }) => {
  return `get post with identity of ${params.identity}`
})

Had we named our route parameter something different, like identity, we'd need to use that name to access the parameter value via params.identity.

Route.get('/posts/:id', (ctx) => {
  return `get post with identity of ${ctx.params.id}`
})

Also remember, we're just extracting params out of our HttpContext object, you can of course just access it directly off the context object as well.

Optional Route Parameters

Now, there are going to be some use-cases where there may or may not be a route parameter needed. In those cases, we can actually make a route's parameter optional by adding a question mark as a suffix to the name.

Route.get('/posts/topics/:topic?', ({ params }) => {
  if (params.topic) {
    return `get all posts for specific topic: ${params.topic}`
  }

  return `get all topics`
})

By adding the question mark at the end of our parameter's name, we're noting that the route parameter is optional. An optional route parameter means our route will match with our without a value for the parameter. In this case, both http://localhost:3333/posts/topics and http://localhost:3333/posts/topics/adonis are going to match this route definition.

Route Order Matters

When you start adding route parameters into your route definitions, the order of your routes begins to matter. When Adonis receives a request for a url, it'll search for a match within our route definitions starting from the top to the bottom of our defined routes. Once a match is found, Adonis will stop searching and will use that first matching route definition to handle the request.

Let's inspect the below as an example.

Route.get('/posts/:id', ({ params }) => {
  return `get post: ${params.id}`
})

Route.get('/posts/topics', ({ params }) => {
  return `get post topics`
})

With the order used here, our second route definition will never be used since topic can be used as the id for /posts/:id. The same applies if we add back the optional parameter to our topics route.

Route.get('/posts/:id', ({ params }) => {
  return `get post: ${params.id}`
})

Route.get('/posts/topics/:topic?', ({ params }) => {
  return `get topics: ${params.topic ?? 'all'}`
})

If we don't provide a topic for our topic route parameter, the second route definition won't be used. This essentially makes our optional topic route parameter required, since the route won't be matched without it.

Solve With Ordering

The first option we have to resolve this issue is ordering. Since Adonis will search for a matching route definition from the top of our file to the bottom, we can simply move our topics definition above our /posts/:id definition. Of course, keep in mind we can never support topic as a value for /posts/:id with this solution.

Route.get('/posts/topics/:topic?', ({ params }) => {
  return `get topics: ${params.topic ?? 'all'}`
})

Route.get('/posts/:id', ({ params }) => {
  return `get post: ${params.id}`
})

Now if we request http://localhost:3333/posts/topics our intended topics route definition will be used since it's the first route definition that'll match.

Solve with Route Validation

The second option we have to solve this issue is by using route validation by using matchers. Adonis actually allows us to add regex validators for our route parameters. Meaning, we can specify that /posts/:id should only match when a numeric value is provided for the id route parameter.

Route.get('/posts/:id', ({ params }) => {
  return `get post: ${params.id}`
}).where('id', /^[0-9]+$/)

Route.get('/posts/topics/:topic?', ({ params }) => {
  return `get topics: ${params.topic ?? 'all'}`
}).where('topic', /^[a-z0-9]+(?:-[a-z0-9]+)*$/gm)

Here we're using the where method to add validation to our two routes. For our /posts/:id route, we're specifying that for our id route parameter we only want it to match for the request if the value is a number. For our /posts/topics/:topic? route, we're specifying we only want it to match if the topic value is alphanumeric containing - as a separator.

With these validations in place, we can safely have our topics route after our /posts/:id route since this route requires a number in the id position and the topics route has the word topic in that position.

Cast Route Parameters

Since Adonis has no way to determine what type a route parameter should come through as it's going to provide all values as strings by default. However, being the accommodating framework that it is, Adonis allows us to cast route parameter values via the definition.

Route.get('/posts/:id', ({ params }) => {
  return `get post: ${params.id}`
}).where('id', {
  match: /^[0-9]+$/,
  cast: (id) => Number(id)
})

To do this, we can make use of the where method to specify what parameter we want to cast, in this case, id. Then, instead of providing just regex as the second argument, we can provide an object. Our regex moves to the match property. Then, we can define a cast property as a callback function, which Adonis will provide us that parameter value that we can then use to cast to whatever type we need it to be, in this case, a number.

Adonis Validator & Cast Utilities

Adonis also has three pre-defined matcher methods we can use that will return back an object with the match and cast defined.

Number
First is a utility for validating a parameter is a number and casting the value to a number. This can be used via Route.matchers.number().

Route.get('/posts/:id', ({ params }) => {
  return `get post: ${params.id}`
}).where('id', Route.matchers.number())

Slug
The second is a utility to verify a URL safe string, also known as a slug. This can be used via Route.matchers.slug().

Route.get('/posts/:slug', ({ params }) => {
  return `get post: ${params.id}`
}).where('slug', Route.matchers.slug())

UUID
Last is a utility to verify a UUID or universally unique identifier. Some folks prefer to use this as an id value instead of a numeric id. This can be used via Route.matechers.uuid().

Route.get('/posts/:id', ({ params }) => {
  return `get post: ${params.id}`
}).where('id', Route.matchers.uuid())

Global Validation & Cast

We can also define a global validation and cast for a particular parameter name by calling the where method directly off the Route module.

Route.where('id', Route.matchers.number())
Route.where('slug', Route.matchers.slug())

Doing this globally will automatically validate all route parameters within our route definitions with the name of id or slug with their given global validation. Additionally, it'll also globally cast the values as well. So now all our id are guaranteed to be numeric and we'll get their values inside our route handlers as numbers instead of strings.

Wildcard Route Parameters

You may come across a use-case where anything beyond a certain point in a url can be absolutely dynamic. An example of this would be if you need to search for something within a physical file structure, like for an image or file. We can make use of wildcard route parameters to make this happen.

Route.get('/img/*', ({ params }) => {
  return params['*']
})

A few things to note here.

  1. Wildcard parameters will match anything. So, it'll match /img/test and /img/this/is/a/test.

  2. We access wildcard route parameters via a property off params called *. So we'd access them with params['*'].

  3. The value of our wildcard parameters will be provided as an array since there's no key to match the values up against. So, for /img/test we'd get an array of ['test']. For img/this/is/a/test we'd get ['this', 'is', 'a', 'test'].

Named & Wildcard Route Parameters

Maybe we have an image server that allows a user to create folders and upload images to those folders. Off our root directory, each user gets their own folder, so we need the user's id to be in the URL for sure, followed by whatever directory path they're trying to access.

For this, we can define a named route parameter for the user's id, followed by a global route parameter.

Route.get('/img/:userId/*', ({ params }) => {
  return {
    userId: params.userId,
    directoryPath: params['*'].join('/')
  }
})

So, we can access this route with all of the following.

  1. /img/1/dogs/boradors/janet.jpg Here our userId route parameter is 1, and our joined directory path is dogs/boradors/janet.jpg.

  2. /img/255/ford/mustangs/1969/blue.jpg Here our userId is 255, and our joined directory path is ford/mustangs/1969/blue.jpg.

Next Up

So, we've taken a deep dive into route parameters and what we can do with them. We've learned the order of our route definitions matters. We've covered that we can validate our routes using matches and that we can also cast our route parameters as needed. We can also validate and cast on a global basis.

In the next lesson, we'll be focusing on the organization and grouping of our routes.