Controllers and routing¶
Controllers are classes annotated with @RestController whose public
instance methods declare routes with HTTP-method annotations. The generator
turns each route method into an adapter that binds parameters, validates
bodies, invokes your method and serializes the result — your controller
code stays plain Dart.
import 'package:diana/diana.dart';
part 'todo_controller.diana.dart'; // required: adapters generate here
@RestController('/todos')
class TodoController {
final TodoService service;
TodoController(this.service); // constructor injection
@Get()
List<TodoDto> list(@QueryParam('limit', '20') int limit) =>
service.list(limit);
@Get('/:id')
TodoDto getById(@PathParam() int id) => service.getOrThrow(id);
@Post()
@ResponseStatus(201)
TodoDto create(@Body() CreateTodoRequest body) => service.create(body);
@Delete('/:id')
void remove(@PathParam() int id) => service.delete(id);
}
Declaring routes¶
@RestController([path = '/', scope = Scope.singleton])— mounts all routes underpath. Controllers join the DI graph; see scopes for request-scoped controllers.@Get,@Post,@Put,@Delete,@Patch— each takes an optional path relative to the controller:@Get('/:id'). Empty means the controller path itself.- Paths use
:namesegments for path parameters (/todos/:id). Routing is a trie — matching is exact-segment based and fast; route registration order never affects matching. - Base and route paths are joined and normalized:
'/todos' + '/:id'→/todos/:id; trailing slashes are dropped. - For methods without a shorthand (HEAD, OPTIONS), use the base annotation:
@HttpRoute('HEAD', '/status'). - Route methods must be public instance methods.
- A request to an existing path with an unregistered method returns 405 Method Not Allowed; an unknown path returns the JSON 404.
Parameter binding¶
Every route-method parameter needs exactly one binding annotation — or
be of type DianaRequest for raw access:
| Annotation | Source | Notes |
|---|---|---|
@PathParam([name]) |
:name path segment |
Must be non-nullable — the segment always exists when the route matches. |
@QueryParam([name, defaultValue]) |
query string | Nullable type ⇒ optional. defaultValue is written as a string and converted like the real value. |
@Header([name]) |
request header | Case-insensitive. Nullable type ⇒ optional. |
@Body() |
JSON request body | See bodies. |
(none — DianaRequest type) |
the raw request | Full access: headers, attributes, body. |
name always defaults to the Dart parameter name.
Supported types for path/query/header: String, int, double,
bool, num — plus their nullable forms for query/header. bool accepts
true/1/false/0 (case-insensitive).
Conversion failures are automatic 400s with precise messages:
GET /todos/abc
→ 400 {"error":"bad_request","message":"Path parameter 'id' must be int, got 'abc'."}
A missing required (non-nullable, no default) query parameter or header is also a 400.
Request bodies¶
@Body() accepts these parameter types:
| Type | Behaviour |
|---|---|
Map<String, dynamic> |
Raw decoded JSON object. |
| A DTO class | Dto.fromJson(decodedMap); the class must expose factory Dto.fromJson(Map<String, dynamic>) (checked at build time). |
List<Dto> |
JSON array, each element through fromJson. |
List<String> / List<int> / … / List<dynamic> |
JSON array of primitives, .cast<T>(). |
Body handling is strict and automatic:
- Content type other than
application/json(or…+json) → 415. A missing content type is tolerated. - Empty or malformed JSON, or the wrong JSON shape (array where an object is expected, and vice versa) → 400.
- If the DTO carries validation constraints, the generated validator runs before your method — an invalid payload never reaches you (structured 400).
- Bodies larger than
server.maxBodyBytes(default 10 MiB) → 413, rejected while streaming, so an oversized body is never buffered in memory. Multipart uploads have their own cap,server.multipart.maxSize(also 10 MiB by default) — raise that one for large file uploads. Setting either key to0disables it. See config keys.
Return types¶
| Declared return type | Response |
|---|---|
a DTO with toJson() |
200, application/json, result.toJson() |
List<Dto> |
200, JSON array of toJson() |
Map<String, dynamic> (and other JSON primitives/collections) |
200, JSON |
String |
200, text/plain |
void |
204 No Content |
DianaResponse |
passed through unchanged (full control) |
Stream<ServerSentEvent> / Stream<Dto> |
200, text/event-stream — see Server-Sent Events |
Future<...> of any of the above |
awaited, then as above |
@ResponseStatus(code)overrides the success status (201for a POST, etc.). It applies to JSON/text/void returns; a returnedDianaResponsekeeps its own status.- A nullable DTO return (
TodoDto?) serializesnullas a JSONnullbody with the success status. - Returning a type with neither
toJson()nor JSON-primitive shape is a build error that tells you what to add. - Bodies and responses default to JSON; routes opt into other formats
(XML, custom codecs) with
@Consumes/@Produces— see Content negotiation.
Errors thrown from handlers¶
Anything thrown inside a route (or the services it calls) flows through the
exception pipeline — built-in HttpExceptions map to their status,
@ControllerAdvice handlers map domain exceptions, everything else becomes
an opaque 500. See Error handling.
Under the hood¶
For each route the generator emits a public adapter in the controller's part file:
Future<DianaResponse> $diana$TodoController$create(
TodoController c,
DianaRequest req,
) async {
final $a0 = CreateTodoRequest.fromJson(await decodeJsonMapBody(req));
const $CreateTodoRequestValidator().validate($a0).throwIfInvalid();
final result = c.create($a0);
return DianaResponse.json(result.toJson(), statusCode: 201);
}
and registers it in the composition root:
server.register(
HttpMethod.post,
'/todos',
(req) => $diana$TodoController$create(ctx.todoController, req),
middleware: [ctx.apiKeyMiddleware],
);
See Generated code for the full picture.
Current limitations¶
- Types referenced in route signatures must be imported without an import prefix in the controller library (generated part files reproduce type names as written, minus prefixes).
- One binding annotation per parameter; combining two is a build error.