Skip to content

Controllers and routing annotations

import 'package:gisila/gisila.dart';
part 'users_controller.g.dart';

These annotations are read at build time by the gisila generator (see Code generation). They have no effect at runtime unless you run build_runner and register the generated attachToApp method with GisilaApp.registerController.

Marks a class as a controller.

class Controller {
const Controller([String path = '']);
final String path;
}

When to use: on every class whose methods should become routes. The path is prepended to every method path. Use one controller per resource (/users, /orders).

Parameter Type Default Description
path String '' Common prefix such as '/users'. May contain placeholders: '/tenants/{tenantId}'.

A controller class with no route methods is skipped with a build warning. Controllers are instantiated once by you, so constructor arguments are app-wide dependencies; per-request state belongs in a Service.

Example

@Controller('/users')
@RequireAuth()
class UsersController {
@Get('/{id}')
Future<User> show(String id, UserService users) => users.getById(id);
}

See also: Controllers concept.

Base class of the HTTP method annotations. The generator recognises any subclass of gisila’s Route.

class Route {
const Route(String method, String path, {
int statusCode = 200,
String contentType = 'application/json',
RouteConfig? config,
});
final String method;
final String path;
final int statusCode;
final String contentType;
final RouteConfig? config;
}

When to use: use the concrete subclasses below. Route itself is useful only if you want a custom verb annotation (subclass it with a const constructor).

Parameter Type Default Description
method String required HTTP verb.
path String required Route path relative to the controller path. Placeholders use braces: '/{id}'.
statusCode int 200 (see subclasses) Status of the success response when the method returns a value or void.
contentType String 'application/json' Declared but currently not used by the generator: value responses are always sent as application/json; charset=utf-8. Return a Response to choose another type.
config RouteConfig? null Per-route rate limit, timeout, body cap, content types and more. Merged on top of @UseRouteConfig and AppConfig.defaultRouteConfig.
const Get(String path, {int statusCode = 200, String contentType, RouteConfig? config});
const Post(String path, {int statusCode = 201, String contentType, RouteConfig? config});
const Put(String path, {int statusCode = 200, String contentType, RouteConfig? config});
const Patch(String path, {int statusCode = 200, String contentType, RouteConfig? config});
const Delete(String path, {int statusCode = 204, String contentType, RouteConfig? config});
const Head(String path, {int statusCode = 200, RouteConfig? config});
const Options(String path, {int statusCode = 204, RouteConfig? config});
Annotation Verb Default success status
Get GET 200
Post POST 201
Put PUT 200
Patch PATCH 200
Delete DELETE 204
Head HEAD 200
Options OPTIONS 204

When to use: one per controller method you want to expose. Override statusCode when the default does not fit, such as 202 Accepted for work queued in the background.

Example

@Post('/imports', statusCode: 202)
Future<Map<String, Object?>> startImport(ImportForm form, ImportService imports) =>
imports.enqueue(form);
@Get('/', config: RouteConfig(rateLimit: RateLimitConfig(requestsPerMinute: 60)))
Future<List<User>> list(UserService users, {int? limit, int? offset}) =>
users.list(limit: limit, offset: offset);
  • Placeholders are written {name} and must be valid Dart identifiers. The generator converts them to shelf_router’s <name> form.
  • The controller path and the method path are joined with exactly one slash.
  • A path ending in / (for example @Get('/') under @Controller('/users')) is registered both as /users/ and /users.
  • Wildcards and regex segments are not supported by the annotations. Mount a hand-written handler on a Router if you need them.

Applies a RouteConfig to every route of a controller.

class UseRouteConfig {
const UseRouteConfig(RouteConfig config);
final RouteConfig config;
}

When to use: controller-wide defaults such as a stricter rate limit or a shorter timeout for an admin controller. For a single route, use the annotation’s config: instead.

@Controller('/admin')
@UseRouteConfig(RouteConfig(
rateLimit: RateLimitConfig(requestsPerMinute: 30),
timeout: Duration(seconds: 3),
))
@Roles({'admin'})
class AdminController { /* ... */ }

Attaches middleware registered by name to a controller or a route.

class UseMiddleware {
const UseMiddleware(String name);
final String name;
}

When to use: middleware that only some routes need (idempotency keys, audit logging, a tenant check). Annotations must be const, so the annotation names the middleware and you register the real one with app.registerNamedMiddleware(name, middleware). Class-level and method-level names are combined (class first, duplicates removed) and run inside the per-route pipeline, after rate limiting and body checks and before the route timeout and auth guard.

app.registerNamedMiddleware('audit', auditMiddleware(auditLog));
@Post('/')
@UseMiddleware('audit')
Future<Order> create(OrderForm form, OrderService orders) => orders.create(form);

An unknown name throws StateError when the handler is built.

Marks a parameter as the request body.

class Body {
const Body({String contentType = 'application/json', bool required = true});
final String contentType;
final bool required;
}

When to use: rarely. The first parameter that is not a path, query, context, form or service parameter already becomes the body. Use @Body() to make that explicit, or to force a coercible type (such as a String) to be read from the body.

Marks a parameter as a query-string parameter.

class Query {
const Query({String? name});
final String? name;
}

When to use: to read a query key whose name differs from the Dart parameter (?page_size= into pageSize), or to force a parameter to come from the query string. Parameters of type String, int, double, num, bool or DateTime that don’t match a path placeholder are query parameters already.

@Get('/')
Future<List<Post>> list(@Query(name: 'page_size') int? pageSize) => ...;

Marks a parameter as a path parameter.

class Path {
const Path({String? name});
final String? name;
}

When to use: when the Dart parameter name differs from the placeholder. A parameter whose name matches a {placeholder} is bound automatically.

@Get('/{user_id}')
Future<User> show(@Path(name: 'user_id') String userId) => ...;

Marks a parameter as a request header.

class Header {
const Header({String? name});
final String? name;
}

When to use: to read a header into a parameter. Headers are never inferred, so the annotation is required. The value is passed as-is (not coerced), and absent headers are null, so declare the parameter as String?.

@Post('/payments')
Future<Payment> charge(
ChargeForm form,
PaymentService payments,
@Header(name: 'Idempotency-Key') String? idempotencyKey,
) => payments.charge(form, idempotencyKey: idempotencyKey);

For each method parameter, the generator picks the first rule that matches:

# Parameter Bound from
1 Annotated with @Path, @Query, @Header or @Body That source.
2 Type Request (Shelf) The raw request as it reached the route.
3 Type RequestContext The request context.
4 Subtype of Form bindForm(request, YourForm.new): parsed and validated. Needs a zero-argument constructor.
5 Subtype of Service ctx.service<YourService>(). Register it with app.registerService.
6 Name matches a {placeholder} in the full path Path parameter, coerced to its type.
7 Type String, int, double, num, bool or DateTime (nullable or not) Query parameter of the same name, coerced with coerce.
8 Anything else, first such parameter The request body (see below).
9 Anything else, after a body was already taken Build error.

Positional and named parameters both work.

Query parameters are required when the parameter is non-nullable and not optional. An empty value (?limit=) counts as missing for every type except String. A value that fails to parse is a 400.

Parameter type What the generated code does
FormData readFormBody: url-encoded or multipart only.
UploadedFile Reads the multipart body and takes the file whose field name equals the parameter name. Missing file is a 400 when required.
List<T> where T has fromJson(Map) readJsonBody, then T.fromJson per element. JSON only.
Other List readJsonBody, passed through. JSON only.
Class with fromForm(Map) and fromJson(Map) readBody; fromForm for form bodies, fromJson for JSON.
Class with only fromForm(Map) or only fromJson(Map) readBody, then that factory (for any content type).
Map / Map<String, dynamic> readBody, cast to Map<String, dynamic>.
Anything else The raw value from readBody.

A TypeError or FormatException while binding any parameter (such as a JSON object missing a field your fromJson casts) becomes a 400 with code bad_request. Exceptions thrown by your method itself are not converted.

Method returns Response
Response / Future<Response> Sent unchanged. Use it for redirects, files, streams, custom headers or non-JSON bodies.
void / Future<void> Empty body with the annotation’s statusCode (so 200 for @Get, 204 for @Delete).
Anything else jsonResponse(body: result, statusCode: statusCode): objects are encoded through their toJson(), DateTime as ISO 8601, enums as their name, and lists and maps recursively.

See also: Build a CRUD API, Code generation concept.