v0.21

controllers

introduction

controllers organize your request handling logic into focused classes. dframework automatically discovers and instantiates the correct controller class when a route is matched. you never construct controllers yourself.

writing controllers

a controller is a class with a default export. each method corresponds to a route action. all methods must be async.

1// controllers/app/IndexController.js
2export default class IndexController {
3 async index() {
4 return render('auth.index');
5 }
6
7 async register() {
8 return render('auth.register');
9 }
10}

you can generate a controller stub using the cli.

1dstrn make:controller app/IndexController

directory structure and naming

controllers live inside the controllers directory and can be organized into subdirectories. the subdirectory and class name are reflected directly in the route definition string.

a controller at controllers/app/HomeController.js is referenced as app.HomeController@method. a controller at controllers/auth/AuthController.js is referenced as auth.AuthController@method. a controller at the root controllers/LocaleController.js is referenced as LocaleController@method.

1Route.get('/home', 'app.HomeController@home');
2Route.post('/register', 'auth.AuthController@register');
3Route.get('/locale/:locale', 'LocaleController@set');

optional arguments

controller methods receive req and res as arguments. both are optional. you only declare the ones your method actually uses. if your method does not need the request at all you omit it entirely.

1export default class IndexController {
2 async index() {
3 return render('auth.index');
4 }
5
6 async user(req) {
7 const targetUser = await User.find(req.params.id);
8 return render('app.user.profile', { targetUser });
9 }
10}

returning responses

controllers return responses using the global helpers that the framework exposes. you never call methods on a response object directly.

1export default class PreferencesController {
2 async index() {
3 const preferences = await UserPreference.getAllForUser(Auth.user().id);
4 return json({ success: true, preferences });
5 }
6
7 async update(req) {
8 await UserPreference.setForUser(Auth.user().id, req.params.key, req.body.value);
9 return json({ success: true });
10 }
11
12 async destroy() {
13 return status(500).json({ success: false, message: 'failed to reset preference' });
14 }
15}

the available response helpers are render(), json(), redirect(), back(), abort(), and status().

NOTE

the abort(statusCode, message) helper is generally preferred over manually setting status(). abort() is context aware: if the request was made via ajax, it automatically returns a json error payload. if it was a standard browser request, it renders the appropriate html error page (e.g. 404, 500).

1export default class UserController {
2 async show(req) {
3 const user = await User.find(req.params.id);
4 if (!user) {
5 // aborts with a 404, returning json for ajax requests or a 404 view for standard requests
6 return abort(404, 'user not found');
7 }
8 return render('app.user.profile', { user });
9 }
10}
IMPORTANT

the redirect(url, code = 302, force = false) helper is strictly protected against open redirect vulnerabilities. it refuses to redirect to cross origin or protocol relative urls, throwing an error instead (except in local development). additionally, it is spa aware. if the request comes from the dSPA router, it returns a json instruction instead of a standard 302 header. setting the force boolean to true forces the spa router to perform a hard page reload on the client side.

1export default class AuthController {
2 async logout(req) {
3 await Auth.logout();
4 // safely redirects to the home page, instructing the spa router to handle it
5 return redirect('/home');
6 }
7}

accessing the request

route parameters

url segments prefixed with : in the route definition are extracted and available on req.params.

1export default class IndexController {
2 async user(req) {
3 const targetUser = await User.find(req.params.id);
4 return render('app.user.profile', { targetUser });
5 }
6}

query strings

query string values are available on req.query as a plain object. the framework parses the query string lazily on first access.

1export default class SearchController {
2 async index(req) {
3 const { q, sort } = req.query;
4 const results = await Track.where('title', q).get();
5 return json({ results });
6 }
7}

request body

the parsed request body is available on req.body. the framework automatically parses json and form encoded bodies on mutating requests before your controller method is called.

1export default class PreferencesController {
2 async update(req) {
3 const { key } = req.params;
4 const { value } = req.body;
5 await UserPreference.setForUser(Auth.user().id, key, value);
6 return json({ success: true, key, value });
7 }
8}

file uploads

when a request is submitted as multipart/form-data the framework parses the files and makes them available on req.files. each key on req.files is an array of file objects.

1import { Storage } from 'dframework';
2
3export default class UserProfileController {
4 async updateProfileImage(req) {
5 const image = req.files.profileImage[0];
6 const path = await Storage.disk('public').put(`user_assets/${Auth.user().id}/${image.originalFilename}`, image);
7 return json({ success: true, path });
8 }
9}

cookies

incoming cookies are available on req.cookies as a plain object. the framework parses the cookie header lazily on first access.

1export default class LocaleController {
2 async set(req) {
3 const preferred = req.cookies.locale;
4 return json({ preferred });
5 }
6}

pagination

dframework reads req.page automatically from the page query string parameter. you use .paginate() on any model query and the framework resolves the current page from the request context without any additional wiring.

1export default class AdminController {
2 async list(req) {
3 const paginatedResult = await User.paginate(20);
4 return json(paginatedResult);
5 }
6}

the .paginate(perPage) method returns an object containing the records and metadata about the pagination state. you can access these properties directly:

validation

the global validate() helper is available inside any controller method. you pass it either the request body object or the full req object when validating file uploads. see the validation documentation for the complete list of available rules.

1export default class UserProfileController {
2 async updateUsername(req) {
3 const validation = validate(req.body, {
4 username: 'required|string|min:3|max:20'
5 });
6
7 if (validation.fails()) {
8 return status(422).json({ success: false, errors: validation.errors() });
9 }
10
11 // proceed with update
12 }
13
14 async updateProfileImage(req) {
15 const validation = validate(req, {
16 profileImage: 'file_required|file_size:8mb|file_max_count:1'
17 });
18
19 if (validation.fails()) {
20 return status(422).json({ success: false, errors: validation.errors() });
21 }
22
23 // proceed with upload
24 }
25}

using models and services

controllers commonly import models and services at the top of the file. because the framework hot reloads controllers in the local environment these imports are reevaluated on every file change.

1import Track from '../../models/Track.js';
2import User from '../../models/User.js';
3
4export default class HomeController {
5 async home(req) {
6 const leaderboard = await User.orderBy('current_rank', 'ASC').limit(5).get();
7 const popularTracks = await Track.where('is_popular', 1).limit(5).get();
8
9 return render('app.home.index', { leaderboard, popularTracks });
10 }
11}

private methods

controllers are plain classes so you can define non async helper methods alongside your route handlers. the framework only calls the method that matches the route definition. any other method on the class is completely private to your logic.

1export default class PreferencesController {
2 async update(req) {
3 const validation = this.validatePreference(req.params.key, req.body.value);
4 if (validation.fails()) {
5 return status(422).json({ success: false, errors: validation.errors() });
6 }
7 await UserPreference.setForUser(Auth.user().id, req.params.key, req.body.value);
8 return json({ success: true });
9 }
10
11 validatePreference(key, value) {
12 return validate({ value }, { value: this.getValidationRules(key) });
13 }
14
15 getValidationRules(key) {
16 const rules = {
17 'audio.normalize_volume': 'boolean',
18 'display.fps_counter': 'boolean',
19 };
20 return rules[key] || 'required';
21 }
22}

hot reloading

in the local environment the framework watches the controllers directory for file changes. when you save a controller file the framework reimports it automatically on the next request without restarting the server. you do not need to take any action to enable this behavior.