Thus far, the two challenge routes we've added are using a callback function to handle the route.
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 ({ view, params }) => { const challenge = challenges.find((row) => row.id === params.id) return view.render('pages/challenges/show', { challenge }) }) // ...Copied!
- start
- routes.ts
This might be fine for small applications, but scales poorly for anything beyond a few routes. The remedy for this is controllers. Controllers are classes with dedicated methods to serve as our route handlers. This moves the logic of our routes from the route definitions into dedicated controller files, typically grouped by resource, like our challenge resource.
Creating Controllers
We can quickly create a new controller using the Ace CLI, but before we do, let's review the help options for this command.
node ace make:controller --help # # Description: # Create a new HTTP controller class # # Usage: # node ace make:controller [options] [--] <name> [<actions...>] # # Arguments: # name The name of the controller # [actions...] Create controller with custom method names # # Options: # -s, --singular Generate controller in singular form # -r, --resource Generate resourceful controller with methods to perform CRUD actions on a resource # -a, --api Generate resourceful controller without the "edit" and the "create" methodsCopied!
The naming convention for controllers is for them to be plural, and AdonisJS will help us out with that by automatically pluralizing the name we provide it for our controller. If we want to bypass that, we can add --singular, or -s for short.
If you'll recall from a few lessons ago, we discussed resourceful naming conventions with index, create, store, edit, update, and delete. To create a resourceful controller with methods stubbed to handle each of those actions, we can add --resource, or -r for short.
If you're working on an API, you won't need the create or edit, so you can create a resource with those omitted with --api or -a for short.
Finally, we could also manually specify methods we want to stub or provide nothing at all to get a simple class. Let's go ahead and create this as a resourceful controller.
node ace make:controller challenge -r # DONE: create app/controllers/challenges_controller.tsCopied!
Controllers can be found within our app/controllers folder. Again, note this automatically pluralized "challenge" to "challenges" for our controller name. Once we jump into this new file, we should see the following.
import type { HttpContext } from '@adonisjs/core/http' export default class ChallengesController { /** * Display a list of resource */ async index({}: HttpContext) {} /** * Display form to create a new record */ async create({}: HttpContext) {} /** * Handle form submission for the create action */ async store({ request }: HttpContext) {} /** * Show individual record */ async show({ params }: HttpContext) {} /** * Edit individual record */ async edit({ params }: HttpContext) {} /** * Handle form submission for the edit action */ async update({ params, request }: HttpContext) {} /** * Delete record */ async destroy({ params }: HttpContext) {} }Copied!
- app
- controllers
- challenges_controller.ts
First thing to note here is that each of the method's arguments is typed with HttpContext. Within our route definition's callback methods, the HttpContext can be automatically typed by the definition. In our controller context, however, that inference can't happen, so a type is needed. Second, AdonisJS has started each method with a request extracted out of the HttpContext for methods expecting data to be sent up, like creating and updating records. Params are extracted for those that typically use a route parameter to identify a single record, like our challenge's id.
So, for us, we want to move our challenges array to the top of this file, so we still have that data to work with.
import type { HttpContext } from '@adonisjs/core/http' 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 }, ] export default class ChallengesController { // ... }Copied!
- app
- controllers
- challenges_controller.ts
Then, we can take the inner contents of our /challenges route definition and move it into our index method. We can also extract view out of the HttpContext for consistency.
import type { HttpContext } from '@adonisjs/core/http' 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 }, ] export default class ChallengesController { /** * Display a list of resource */ async index({ view }: HttpContext) { return view.render('pages/challenges/index', { challenges }) } // ... }Copied!
- app
- controllers
- challenges_controller.ts
Then, we can do the same for our /challenges/:id route definition. This route's job is to show a single record, so its resourceful method will be show.
import type { HttpContext } from '@adonisjs/core/http' 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 }, ] export default class ChallengesController { /** * Display a list of resource */ async index({ view }: HttpContext) { return view.render('pages/challenges/index', { challenges }) } // ... /** * Show individual record */ async show({ view, params }: HttpContext) { const challenge = challenges.find((row) => row.id === params.id) return view.render('pages/challenges/show', { challenge }) } // ... }Copied!
- app
- controllers
- challenges_controller.ts
Using Controllers
We then need to update our route definitions to point to these controller methods instead of using the callback function. For this, we'll provide an array as the second argument to our route definition with our controller and the designated method to be used to handle the route. You'll also notice that AdonisJS provides a nice type inference to give us an autocomplete list of the controller's methods to pick from as well.
import { controllers } from '#generated/controllers' import { middleware } from '#start/kernel' import router from '@adonisjs/core/services/router' const ChallengesController = () => import('#controllers/challenges_controller') 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', [ChallengesController, 'index']) router.get('/challenge/:id', [ChallengesController, 'show']) // ...Copied!
- start
- routes.ts
Here, AdonisJS prefers lazy-loaded controllers, hence the wrapped import. This helps ensure a speedy boot as we get more and more controllers to be imported.
Barrel Files & Subpath Imports
New in AdonisJS 7 is a new barrel file generation step. If we look at the top of our imports and the starter kit's pre-defined routes, we'll notice controllers is being imported from a #generated/controllers location.
This location is utilizing a NodeJS Subpath Import, which is an alias import location to simplify our import paths throughout our project. Let's start here, these are defined within our package.json file, and AdonisJS comes with several out-of-the-box.
// package.json { "": "...", "imports": { "#controllers/*": "./app/controllers/*.js", "#exceptions/*": "./app/exceptions/*.js", "#models/*": "./app/models/*.js", "#mails/*": "./app/mails/*.js", "#services/*": "./app/services/*.js", "#listeners/*": "./app/listeners/*.js", "#generated/*": "./.adonisjs/server/*.js", "#events/*": "./app/events/*.js", "#middleware/*": "./app/middleware/*.js", "#validators/*": "./app/validators/*.js", "#providers/*": "./providers/*.js", "#policies/*": "./app/policies/*.js", "#abilities/*": "./app/abilities/*.js", "#database/*": "./database/*.js", "#tests/*": "./tests/*.js", "#start/*": "./start/*.js", "#config/*": "./config/*.js" }, "": "...", }Copied!
This is saying, anytime we're importing from #generated to look within ./.adonisjs/server/ from our project root. So, our #generated/controllers import location is actually ./.adonisjs/server/controllers.ts within our project.
The .adonisjs folder is a new folder to AdonisJS 7 for these automatically generated files to improve type-safety throughout AdonisJS applications. Generated types and files to be used on the server-side will be within .adonisjs/server, while client-side safe types will be within .adonisjs/client. Since these are generated, they shouldn't be manually updated, as any updates will be overwritten.
So, if we open our generated controllers file, we'll see something like:
// .adonisjs/server/controllers.ts export const controllers = { Challenges: () => import('#controllers/challenges_controller'), NewAccount: () => import('#controllers/new_account_controller'), Session: () => import('#controllers/session_controller'), }Copied!
These generated files are created during and while our dev server is booted. So if you stop your server and create a controller, this won't update until we boot the dev server back up.
As we can see, this is an object of keys named after our controllers pointing to their import locations, again using lazy-loading as well to keep things loading efficiently. This is called a barrel file, and its purpose is to cut back on import clutter. Rather than importing every controller our application uses, we can instead import this one controllers variable and reference the controller's import location from here.
So, to use this, we can swap our import and class for controllers.Challenges. This, too, will give us a type inference autocomplete list of methods from the controller, same as we had before!
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', [controllers.Challenges, 'index']) router.get('/challenge/:id', [controllers.Challenges, 'show']) // ...Copied!
- start
- routes.ts
When we request our /challenges route now, AdonisJS will instantiate an instance of our ChallengesController and execute the index method, passing it the HttpContext. Each request gets its own isolated controller instance.
Resources
Since we're using a resourceful controller, AdonisJS provides a utility route definition to automatically define a route for each of the resourceful actions. So, we could define all six of these resourceful routes for our challenges with:
router.resource('challenges', controllers.Challenges)Copied!
- start
- routes.ts
This takes the name we've provided to define the base of all these routes as /challenges and it will then add the id route parameter where appropriate. The route handlers will also map automatically to the correct controller method for the route as well.
If we created an API resource, we could add apiOnly() to this to omit the create and edit routes. We can also use only() to specify specific resource routes to define.
router.resource('challenges', controllers.Challenges).only(['index', 'show'])Copied!
- start
- routes.ts
We have a little more to learn yet, however, so I'm going to keep these as separate routes for now.
router.get('/challenges', [controllers.Challenges, 'index']) router.get('/challenge/:id', [controllers.Challenges, 'show'])Copied!
- start
- routes.ts