Routing
A route connects a URL and an HTTP method to the code that answers it.
This is the path of one request through the router:
flowchart LR
Q["Request<br>GET /blogs/42"] --> B["Router<br>method, host, path"]
B -->|no match| N["404"]
B -->|match| P["Params as text<br>req.param"]
P --> M["Middleware"]
M -->|returns a value| S["Response"]
M -->|returns the request| T["Typed params, DTO<br>int id"]
T --> C["Controller"]
C --> S
Where routes live
Routes are in the lib/routes folder. Each file has one Router class.
The prefix of the router is added in front of all its routes.
import 'package:dox_core/dox_core.dart';
class ApiRouter extends Router {
@override
String get prefix => 'api';
@override
List<dynamic> get middleware => <dynamic>[];
@override
void register() {
Route.get('/ping', (DoxRequest req) => 'pong'); // GET /api/ping
}
}
Dox finds every Router class by itself. It is in lib/generated/container.g.dart after dart run build_runner build.
To pick the order of routers, list them in routers: of AppConfig.
See Dependency injection: routers and providers.
The leading / in a path is optional. 'ping' and '/ping' are the same route.
Route methods
Route.get('/blogs', (BlogController c) => c.index);
Route.post('/blogs', (BlogController c) => c.store);
Route.put('/blogs/{id}', (BlogController c) => c.update);
Route.patch('/blogs/{id}', (BlogController c) => c.update);
Route.delete('/blogs/{id}', (BlogController c) => c.destroy);
Info
There are also Route.options, Route.copy, Route.view, Route.link, Route.unlink,
Route.purge, Route.lock, Route.unlock and Route.propfind.
These are HTTP methods. Route.view is the WebDAV VIEW method. It does not render a template like Laravel Route::view.
To show a template, use a GET route: Route.get('welcome', (DoxRequest req) => view(req, 'welcome')) with view() of dox_view.
Route.health('/health') adds a health check route. It answers 200 with {"status": "ok"}.
Route.websocket('echo', (WebSocket socket, DoxRequest req) {...}) adds a raw WebSocket route.
By default any website can open it from a browser, with the cookies of the user. To check the Origin, pass allowedOrigins: ['https://app.example.com'], or set AppConfig(webSocketAllowedOrigins: [...]) for every WebSocket route. Then only pages of the same host and the listed sites can open it. Other origins get 403. [] means the same host only.
When a WebSocket route has no check, Dox prints one warning to stderr at start.
A message over maxMessageSize bytes (default 16 MB) closes the socket with code 1009. Example: Route.websocket('chat', handler, maxMessageSize: 64 * 1024).
A HEAD request runs the GET route of the same path. The answer has the status and headers of GET, with no body.
More than one method
Route.match adds the same handler for a list of methods. Route.any adds it for GET, POST, PUT, PATCH and DELETE.
Route.match(<String>['GET', 'POST'], '/contact', (ContactController c) => c.handle);
Route.any('/webhook', (WebhookController c) => c.handle);
HEADcomes withGET, so do not list it. An unknown method throws anArgumentErrorat start.Route.anydoes not addOPTIONS. So Dox still answers the CORS preflight request for the path..where(...)and.timeout(...)afterRoute.matchorRoute.anywork on every route it added..name(...)names the last one.
Redirect routes
Route.redirect sends a path to another URL. The status is 302 by default.
Route.redirect('/home', '/');
Route.redirect('/old-blog', '/blog', status: 301);
Route.permanentRedirect('/old-pricing', '/pricing'); // 301
The redirect works for the same methods as Route.any.
- The target is sent as it is. A param like
{id}in the target is not filled in:Route.redirect('/u/{id}', '/users/{id}')sends the client to/users/{id}. - The query string of the request is dropped.
/home?page=2goes to/, not/?page=2. - For these cases, use a normal route and return
redirect(...)with the URL you build.
The handler
The second argument is the code that answers the request. It can be:
// a function
Route.get('/ping', (DoxRequest req) => 'pong');
// a controller method. The controller comes from the container.
Route.get('/ping', (WebController c) => c.pong);
@Inject()
class WebController {
String pong(DoxRequest req) {
return 'pong';
}
}
// a list: middleware first, the controller last
Route.get('/ping', <dynamic>[
LoggerMiddleware(),
(WebController c) => c.pong,
]);
In a list, the last item is the controller. The items before it are middleware.
(WebController c) => c.pong picks the method. Dox calls it once per isolate, at start.
See Dependency injection.
See Controller for the parameters a handler can ask for.
Route params
Put a param in {}. Ask for it as a typed parameter in the controller.
Route.get('/blogs/{id}', (BlogController c) => c.show);
@Inject()
class BlogController {
Future<Blog?> show(int id) async {
return Blog().find(id);
}
}
/blogs/abc answers 404, because abc is not an int.
See Controller: parameters for all the rules.
Optional param
{name?} can be missing. Use a nullable type.
Route.get('/posts/{page?}', (int? page) {
return 'page ${page ?? 1}'; // GET /posts and GET /posts/2 both match
});
Catch-all param
{name*} matches the rest of the path, with slashes.
Param rules
.where(param, regex) lets a route match only when the param text matches the regex.
When it does not match, the router tries the next route. When no route matches, the client gets 404.
Route.get('/users/{id}', (UserController c) => c.show).where('id', '[0-9]+');
Route.get('/users/{name}', (UserController c) => c.byName); // gets the rest
Route.get('/files/{type}', handler).where('type', 'png|jpg');
Route.get('/docs/{page}', handler).where('page', '.*'); // matches slashes too
- The regex must match the whole param text. You do not need
^or$. A leading^and a trailing$are dropped. - Use
(?:...)for a group, not(...). A regex with a(...)group throws anArgumentErrorat start. - A param name that the path does not have throws an
ArgumentErrorat start. - Call
.wheremore than once for more params. - Routes without
.wherematch the same as before. The check costs nothing for them. - The regex checks the param text two times: as it is in the URL, and again after
%codes are decoded. Both checks must pass. A%code for a letter, a digit or-._~is decoded before the router sees the path, so/users/%31is/users/1and matches[0-9]+. Other%codes stay in the URL text and must pass the second check. So/files/x%2Fydoes not match[^/]+, because%2Fis/. The controller gets the decoded value. In the second check,.also matches a new line (%0A), so.*still matches it.
Group routes
// by prefix
Route.group('/blogs', () { // (1)!
Route.get('/', (BlogController c) => c.index); // GET /blogs
Route.get('/{id}', (BlogController c) => c.show); // (2)!
});
// by domain
Route.domain('dartondox.com', () { // (3)!
Route.get('/ping', (WebController c) => c.pong);
});
// by middleware
Route.middleware(<dynamic>[AuthMiddleware()], () { // (4)!
Route.get('/me', (AuthController c) => c.me);
Route.post('/logout', (AuthController c) => c.logout);
});
/blogsgoes in front of every path in the callback. Dox puts one/between the parts.'blogs'and'/blogs/'work the same.- Makes
GET /blogs/{id}. - These routes match only when the
Hostheader isdartondox.com. Other hosts get404. AuthMiddlewareruns beforemeandlogout. It runs after the global and router middleware.
Groups can be nested. Route.group('blogs', ...) with Route.get('all', ...) makes /blogs/all.
A group can also add a prefix to the route names inside it, with name:.
To turn CORS on or off for some routes, see CORS.
Resource routes
One line makes the usual CRUD routes for a controller.
BlogController is a class from the container. A controller object works too: Route.resource('blogs', BlogController()).
| Method | Path | Controller method | Route name |
|---|---|---|---|
GET |
/blogs |
index |
blogs.index |
GET |
/blogs/create |
create |
blogs.create |
POST |
/blogs |
store |
blogs.store |
GET |
/blogs/{id} |
show |
blogs.show |
GET |
/blogs/{id}/edit |
edit |
blogs.edit |
PUT, PATCH |
/blogs/{id} |
update |
blogs.update |
DELETE |
/blogs/{id} |
destroy |
blogs.destroy |
The name comes from the path: / becomes ., and {params} are left out.
So Route.resource('users/{user}/posts', ...) gives users.posts.index.
Pass name: to pick another one: Route.resource('blogs', BlogController, name: 'posts').
A method that the controller does not have is skipped. No route is made for it. Any other error from the controller is not hidden. It stops the app at start, so you see it. See Resource controller to make one with the CLI.
Forms with PUT, PATCH and DELETE
An HTML form can only send GET or POST. To reach a PUT, PATCH or DELETE route, add MethodOverride to the global middleware.
Then send the form as POST with a _method field.
- Only a POST request changes, and only to
PUT,PATCHorDELETE. Other values are ignored. req.methodgives the new method.CsrfMiddlewarestill checks the request, so send the_tokenfield too.- It works only in
globalMiddleware, not on a router or a route. It is off by default.
A client that can not send PUT, like an old proxy or JS client, can send the X-HTTP-Method-Override header instead. Turn it on with header: true. It is off by default.
The header has the same rules as _method. When both are there, the header wins.
Named routes
Give a route a name with .name(). Then build its URL from the name, so a path lives in one place.
Route.get('/users/{id}', (UserController c) => c.show).name('users.show');
route('users.show', params: <String, Object>{'id': 5});
// /users/5
route('users.show', params: <String, Object>{'id': 5}, query: <String, Object>{'tab': 'posts'});
// /users/5?tab=posts
route() gives the path. Route.url() does the same.
Param values are URL encoded. A missing {name?} param is left out.
A group can put a prefix in front of the names inside it:
Route.group('admin', () {
Route.get('users', (UserController c) => c.index).name('users'); // admin.users
}, name: 'admin.');
Dox stops with a clear error when:
- two routes have the same name (at start),
route()gets a name that no route has,- a
{param}of the path has no value, orparamshas a key the path does not have.
Signed routes
A signed URL is a named route with a signature in its query, so nobody can change it. Use it for links in emails, like "unsubscribe".
Protect the route with ValidateSignature, and make the URL with Route.signedUrl:
Route.get('/unsubscribe/{user}', <dynamic>[
const ValidateSignature(),
(UserController c) => c.unsubscribe,
]).name('unsubscribe');
Route.signedUrl('unsubscribe',
params: <String, Object>{'user': 5}, expires: Duration(days: 7));
// https://example.com/unsubscribe/5?expires=...&signature=...
A changed or expired URL gets 403. See Signed URLs.
Route model binding
A binder turns a route param into a value before the controller runs, for example a model from the database.
Add it with Route.bind, before the routes that use it.
Route.bind<User>('user', (String id) => User().find(id));
Route.get('/users/{user}', (DoxRequest req, User user) {
return user;
});
Every route with a {user} param now runs the binder first.
The binder gets the raw text from the URL. It returns the value, or null when there is none.
null answers 404, the same way as a thrown NotFoundHttpException.
There are two ways to read the value:
- Ask for a parameter of the bound type, like
User user. Dox matches it by type, so it can be at any place in the parameter list. - Read it from the request:
req.bound<User>('user'). This also works in a controller that only takesDoxRequest.
Other path params still work next to it:
The binder runs after the middleware, so an auth middleware can stop the request before any database call.
A @DoxModel class does not need Route.bind. Dox makes a binder for it in lib/generated/container.g.dart.
See Dependency injection: route model binding.
Route.bind wins when both exist.
Serve static files
Route.staticFiles(prefix, folder) serves the files of a folder, like CSS, JavaScript and images.
Route.staticFiles('/', 'public');
Route.staticFiles('build', 'public/build', maxAge: Duration(days: 365));
- Only
GETandHEADrequests read files, and only when no route matches. A route always wins. .., dot files (like.envor.git) and folders are never sent. They get404.- The one dot folder that is sent is
.well-knownat the top of the folder (public/.well-known/...). It holds ACME (Let's Encrypt) challenges,security.txtandapple-app-site-association. Dot files inside it still get404. - A file gets a weak
ETag(from its size and change time) andLast-Modified. The browser gets304with no body when it already has the file.If-None-Matchwins overIf-Modified-Since. Range requests work. WithIf-Range, the Range is used only when it is the file'sLast-Modifieddate. Else the whole file is sent. maxAgesendsCache-Control: public, max-age=.... Give a longmaxAgeto build files with a hash in the name (like Vite's).- No middleware runs for a file. The global CORS config still applies.
- Like other routes, the prefix follows
Route.group, the router prefix andRoute.domain. - The folder path is relative to the folder the app starts in. Symlinks in the folder are followed.
Warning
Never save user uploads in this folder. An uploaded html or svg file would run its script on your site.
Use dox_storage for uploads. It sends such files as downloads.
Behind a proxy, the proxy can serve the folder instead. Then the files never reach Dox.
Fallback route
Route.fallback answers a GET or HEAD request that matches no route and no static file.
Use it for a custom 404 page, or to send index.html of a single page app.
class WebRouter extends Router {
@override
void register() {
Route.get('/', (WebController c) => c.home);
Route.fallback((DoxRequest req) {
return response(<String, String>{'message': 'Page not found'}).statusCode(404);
});
}
}
- Other methods still get the normal
404or405. - A
GETto a path that only has aPOSTroute gets the fallback, if a fallback covers that path. If none does, the client gets405. - Add the fallback before
startServer(), like other routes. A fallback added later only works in the isolate that added it. The other isolates do not get it. - The fallback runs its middleware like any route. Add it in a
Router, so the global middleware (like security headers) runs too. - It follows
Route.group, the router prefix andRoute.domain. In a group, it only answers paths under the group prefix. A prefix can have params, like{tenant}, andreq.paramgives them. When two fallbacks match, the one with the longest prefix wins. - The handler decides the status. Return a
404status for a "not found" page, else the client gets200.
Note
The built-in 404 and 405 answers do not run the global middleware.
Running it there could change answers for apps today. For example, CsrfMiddleware would answer 419 to a POST to an unknown path, and SessionMiddleware would start a session for every bad link.
Use Route.fallback in a router when your 404 page needs the global middleware.
Serve storage files
Storage.route(path) from the dox_storage package serves the files of a storage disk at GET <path>/{key*}.
By default only signed URLs work. Other requests get 403.
See File Storage.
An html, svg, xml or javascript file is sent as a download (Content-Disposition: attachment) with Content-Security-Policy: sandbox.
So a script in an uploaded file does not run on your site.
Every file response also has X-Content-Type-Options: nosniff, so the browser does not guess another type.
For user uploads, download: true sends every file as a download, also images and PDFs:
Request body for the docs
post, put and patch take a body: schema. It shows in the OpenAPI docs.
It does not check the request. To check input, use a DTO.
Route.post('/posts', (PostController c) => c.store, body: v.object(<String, Schema<Object?>>{
'title': v.string().min(3),
}));
Mount a raw handler
Route.mount(prefix, handler) sends every request under a path prefix straight to a raw HttpRequest handler.
It runs before routing and before the body is read. The handler writes and closes the response itself.
Socket.IO uses it for /socket.io/.
How the prefix matches:
- It matches whole path parts.
/adminmatches/admin,/admin/and/admin/users. It never matches/administratoror/admin-login. - A trailing
/does not change the match./socket.io/also matches/socket.io. - The query string is not part of the match.
- When two prefixes match, the longest one wins.
//counts as one/, so/admin//secure/xgoes to a mount at/admin/secure. - The match uses the raw path.
%2Fstays encoded, so/admin/secure%2Fxgoes to a mount at/admin. To protect a nested path, setmiddleware: trueon the outer mount too.
Warning
By default a mount skips all middleware, also the global middleware (security headers, CSRF, rate limit).
Set middleware: true to run AppConfig.globalMiddleware before the handler.
The global CORS config never applies to a mount. The handler must send its own CORS headers.
With middleware: true:
- A middleware that gives a response (like
403) stops the request. The handler does not run. - The body is not read for the middleware, so the handler can still read it. A CSRF token must come in a header.
- The handler sends the answer itself. So the part of a
nextmiddleware that runs after the handler cannot change the answer. - A
terminatestep runs once the answer is sent, like on other routes. RequestTimeoutdoes not run for a mount. The handler already owns the answer.- Cookies that a middleware adds after the handler (the session cookie,
XSRF-TOKEN) are not sent. Get the CSRF token from a normal route. - An error in the handler gives
500, like without middleware.