Skip to content

ResourceController

A ResourceController is a controller that provide conveniences for implementing endpoint controllers. A ResourceController must be subclassed, and in that subclass, you write a method for each operation on that type of resource. For example, a UserController might handle the following operations:

  • creating a new user (POST /users)
  • getting all users (GET /users)
  • getting an individual user (GET /users/:id)
  • updating an individual user (PUT /users/:id)
  • deleting an individual user (DELETE /users/:id)

These methods that are invoked for an operation are called operation methods.

Operation Methods

An operation method is an instance method of a ResourceController subclass that has an @Operation annotation. It must return an instance of Future<Response>. Here's an example:

class CityController extends ResourceController {
  @Operation.get()
  Future<Response> getAllCities() async {
    return Response.ok(["Atlanta", "Madison", "Mountain View"]);
  }
}

The above operation method will be invoked when CityController handles GET requests without path variables. To handle operation methods with path variables, the name of the path variable is added to the @Operation annotation:

class CityController extends ResourceController {
  @Operation.get()
  Future<Response> getAllCities() async {
    return Response.ok(["Atlanta", "Madison", "Mountain View"]);
  }

  @Operation.get('name')
  Future<Response> getCityByName() async {
    final id = request.path.variables['name'];
    return Response.ok(fetchCityWithName(name));
  }
}

!!! note "Path Variables" This controller would be linked to the route specification /cities/[:name], so that it can handle both of these operations. Read more about path variables in Routing.

The named constructor of Operation tells us which HTTP method the operation method handles. The following named constructors exist:

  • Operation.post()
  • Operation.get()
  • Operation.put()
  • Operation.delete()

The canonical Operation() constructor takes the HTTP method as its first argument for non-standard operations, e.g.:

@Operation('PATCH', 'id')
Future<Response> patchObjectWithID() async => ...;

All Operation constructors take a variable list of path variables. There can be multiple path variables for an operation. An operation method will only be invoked if all of its path variables are present in the request path. There can be multiple operation methods for a given HTTP method, as long as each expects a different set of path variables.

Here's an example of an operation that requires two path variables:

@Operation.get('userID', 'itemID')
Future<Response> getUserItem() async {
  final userID = request.path.variables['userID'];
  final itemID = request.path.variables['itemID'];
  return Response.ok(...);
}

If no operation method exists for a request, a 405 Method Not Allowed response is automatically sent and no operation method is invoked.

Routing to a ResourceController

A ResourceController subclass must be preceded by a Router in the application channel. The Router will parse path variables so that the controller can use them to determine which operation method should be invoked. A typical route to a ResourceController contains an optional identifying path variable:

router
  .route("/cities/[:name]")
  .link(() => CityController());

This route would allow CityController to implement operation methods for all HTTP methods with both no path variables and the 'name' path variable.

It is considered good practice to break sub-resources into their own controller. For example, the following is preferred:

router
  .route("/cities/[:name]")
  .link(() => CityController());

router
  .route("/cities/:name/attractions/[:id]")
  .link(() => CityAttractionController());

By contrast, the route /cities/[:name/[attractions/[:id]]], while valid, makes controller logic much more unwieldy.

Request Bindings

Operation methods may bind properties of an HTTP request to its parameters. When the operation method is invoked, the value of that property is passed as an argument to the operation method. For example, the following binds the header named 'X-API-Key' to the argument apiKey:

@Operation.get('name')
Future<Response> getCityByName(@Bind.header('x-api-key') String apiKey) async {
  if (!isValid(apiKey)) {
    return Response.unauthorized();
  }

  return Response.ok(...);
}

The following table shows the possible types of bindings:

Property Binding
Path Variable @Bind.path(pathVariableName)
URL Query Parameter @Bind.query(queryParameterName)
Header @Bind.header(headerName)
Request Body @Bind.body()

You may bind any number of HTTP request properties to a single operation method.

Optional Bindings

Bindings can be made optional. If a binding is optional, the operation method will still be called even if the binding isn't in a request. To make a binding optional, wrap it in curly brackets.

@Operation.get()
Future<Response> getAllCities({@Bind.header('x-api-key') String apiKey}) async {
  if (apiKey == null) {
    // No X-API-Key in request
    ...
  }
  ...
}

The curly bracket syntax is a Dart language feature for optional method parameters (see more here). If there are multiple optional parameters, use only one pair of curly brackets and list each optional parameter in those brackets.

A bound parameter will be null if is not present in the request. You can provide a default value for optional parameters.

@Operation.get()
Future<Response> getAllCities({@Bind.header("x-api-key") String apiKey: "public"}) async {
  ...
}

Automatically Parsing Bindings

Query, header and path bindings can automatically be parsed into other types, such as int or DateTime. Simply declare the bound parameter's type to the desired type:

Future<Response> getCityByID(@Bind.query('id') int cityID)

The type of a bound parameter may be String or any type that implements parse (e.g., int, DateTime). Query parameters may also be bound to bool parameters; a boolean query parameter will be true if the query parameter has no value (e.g. /path?boolean).

If parsing fails for any reason, an error response is sent and the operation method is not called. For example, the above example binds int cityID - if the path variable 'id' can't be parsed into an int, a 404 Not Found response is sent. If a query parameter or header value cannot be parsed, a 400 Bad Request response is sent.

You may also bind List<T> parameters to headers and query parameters, where T must meet the same criteria as above. Query parameters and headers may appear more than once in a request. For example, the value of ids is [1, 2] if the request URL ends with /path?id=1&id=2 and the operation method looks like this:

Future<Response> getCitiesByIDs(@Bind.query('id') List<int> ids)

Note that if a parameter is not bound to a list and there are multiple occurrences of that property in the request, a 400 Bad Request response will be sent. If you want to allow multiple values, you must bind to a List<T>.

Header Bindings

The following operation method binds the header named X-API-Key to the apiKey parameter:

class CityController extends ResourceController {
  @Operation.get()
  Future<Response> getAllCities(@Bind.header('x-api-key') String apiKey) async {
    if (!isValid(apiKey)) {
      return Response.unauthorized();
    }

    return Response.ok(['Atlanta', 'Madison', 'Mountain View']);
  }
}

If an X-API-Key header is present in the request, its value will be available in apiKey. If it is not, getAllCities(apiKey) would not be called and a 400 Bad Request response will be sent. If apiKey were optional, the method is called as normal and apiKey is null or a default value.

Header names are case-insensitive per the HTTP specification. Therefore, the header name may be 'X-API-KEY', 'X-Api-Key' 'x-api-key', etc. and apiKey will be bound in all cases.

Query Parameter Bindings

The following operation methods binds the query parameter named 'name' to the parameter cityName:

class CityController extends ResourceController {
  @Operation.get()
  Future<Response> getAllCities(@Bind.query('name') String cityName) async {
    return Response.ok(cities.where((c) => c.name == cityName).toList());
  }
}

Query parameters can be required or optional. If required, a 400 Bad Request response is sent and no operation method is called if the query parameter is not present in the request URL. If optional, the bound variable is null or a default value.

Query parameters are case-sensitive; this binding will only match the query parameter 'name', but not 'Name' or 'NAME'.

Query parameters may also bound for query strings in the request body when the content-type is 'application/x-www-form-urlencoded'.

Path Variable Bindings

The following operation method binds the path variable 'id' to the parameter cityID:

class CityController extends ResourceController {
  @Operation.get('id')
  Future<Response> getCityByID(@Bind.path('id') String cityID) async {
    return Response.ok(cities.where((c) => c.id == cityID).toList());
  }
}

Path variables are made available when creating routes. A Router must have a route that includes a path variable and that path variable must be listed in the Operation annotation. Path variables are case-sensitive and may not be optional.

If you attempt to bind a path variable that is not present in the Operation, you will get a runtime exception at startup. You do not have to bind path variables for an operation method to be invoked.

HTTP Request Body Bindings

The body of an HTTP request can also be bound to a parameter:

class CityController extends ResourceController {
  CityController(this.context);

  final ManagedContext context;

  @Operation.post()
  Future<Response> addCity(@Bind.body() City city) async {
    final insertedCity = await context.insertObject(city);

    return Response.ok(insertedCity);
  }
}

Since there is only one request body, Bind.body() doesn't take any identifying arguments (however, it does take optional arguments for ignoring, requiring or rejecting keys; this matches the behavior of Serializable.read and only works when the bound type is a Serializable or list of).

The bound parameter type (City in this example) must implement Serializable. Conduit will automatically decode the request body from it's content-type, create a new instance of the bound parameter type, and invoke its read method. In the above example, a valid request body would be the following JSON:

{
  "id": 1,
  "name": "Atlanta"
}

!!! note "HTTP Body Decoding" Request bodies are decoded according to their content-type prior to being deserialized. For more information on request body decoding, including decoding content-types other than JSON, see this guide.

If parsing fails or read throws an exception, a 400 Bad Request response will be sent and the operation method won't be called.

You may also bind List<Serializable> parameters to the request body. Consider the following JSON that contains a list of cities:

[
  {"id": 1, "name": "Atlanta"},
  {"id": 2, "name": "Madison"}
]

This body can be bound by declaring the bound parameter to be a List of the desired type:

Future<Response> addCity(@Bind.body() List<City> cities)

!!! tip "List vs Object" An endpoint should either take a single object or a list of objects, but not both. If the request body is a JSON list and the bound variable is not a list, a 400 Bad Request response will be sent (and vice versa). Declaring a body binding of the appropriate type validates the expected value and aids in automatically generating an OpenAPI specification for your application.

Note that if the request's Content-Type is 'x-www-form-urlencoded', its must be bound with Bind.query and not Bind.body.

!!! tip "Key Filters in Bind.body()" Filters can be applied to keys of the object being read. Filters can ignore keys, require keys or throw an error if a key is found. See more here.

Property Binding

The properties of an ResourceControllers may also have Bind.query and Bind.header metadata. This binds values from the request to the ResourceController instance itself, making them accessible from all operation methods.

class CityController extends ResourceController {
  @requiredBinding
  @Bind.header("x-timestamp")
  DateTime timestamp;

  @Bind.query("limit")
  int limit;

  @Operation.get()
  Future<Response> getCities() async {
      // can use both limit and timestamp
  }
}

In the above, both timestamp and limit are bound prior to getCities being invoked. By default, a bound property is optional. Adding an requiredBinding annotation changes a property to required. If required, any request without the required property fails with a 400 Bad Request status code and none of the operation methods are invoked.

Other ResourceController Behavior

Besides binding, ResourceControllers have some other behavior that is important to understand.

Request and Response Bodies

A ResourceController can limit the content type of HTTP request bodies it accepts. By default, a ResourceController will accept only application/json request bodies for its POST and PUT methods. This can be modified by setting the acceptedContentTypes property in the constructor.

class UserController extends ResourceController {
  UserController() {
    acceptedContentTypes = [ContentType.JSON, ContentType.XML];
  }
}

If a request is made with a content type other than the accepted content types, the controller automatically responds with a 415 Unsupported Media Type response.

The body of an HTTP request is decoded if the content type is accepted and there exists a operation method to handle the request. The body is not decoded if there is not a matching operation method for the request. The body is decoded by ResourceController prior to your operation method being invoked. Therefore, you can always use the synchronous RequestBody.as method to access the body from within an operation method:

@Operation.post()
Future<Response> createThing() async {
  // do this:
  Map<String, dynamic> bodyMap = request.body.as();

  // no need to do this:
  Map<String, dynamic> bodyMap = await request.body.decode();

  return ...;
}

A ResourceController can also have a default content type for its responses. By default, this is application/json. This default can be changed by changing responseContentType in the constructor:

class UserController extends ResourceController {
  UserController() {
    responseContentType = ContentType.XML;
  }
}

The responseContentType is the default response content type. An individual Response may set its own contentType, which takes precedence over the responseContentType. For example, the following controller returns JSON by default, but if the request specifically asks for XML, that's what it will return:

class UserController extends ResourceController {
  UserController() {
    responseContentType = ContentType.JSON;
  }

  @Operation.get('id')
  Future<Response> getUserByID(@Bind.path('id') int id) async {
    var response = Response.ok(...);

    if (request.headers.value(Bind.headers.ACCEPT).startsWith("application/xml")) {
      response.contentType = ContentType.XML;
    }

    return response;
  }
}

More Specialized ResourceControllers

Many ResourceController subclasses will execute queries. There are helpful ResourceController subclasses for reducing boilerplate code.

A QueryController<T> builds a Query<T> based on the incoming request. If the request has a body, this Query<T>'s values property is read from that body. If the request has a path variable, the Query<T> assigns an expression to the primary key value. For example, in a normal ResourceController that responds to a PUT request, you might write the following:

@Operation.put('id')
Future<Response> updateUser(@Bind.path('id') int id, @Bind.body() User user) async {
  var query = Query<User>(context)
    ..where((u) => u.id).equalTo(id)
    ..values = user;

  return Response.ok(await query.updateOne());
}

A QueryController<T> builds this query before a operation method is invoked, storing it in the inherited query property. A ManagedObject<T> subclass is the type argument to QueryController<T>.

class UserController extends QueryController<User> {
  UserController(ManagedContext context) : super(context);

  @Operation.put('id')
  Future<Response> updateUser(@Bind.path('id') int id) async {
    // query already exists and is identical to the snippet above
    var result = await query.updateOne();
    return Response.ok(result);
  }
}

A ManagedObjectController<T> is significantly more powerful; you don't even need to subclass it. It does all the things a CRUD endpoint does without any code. Here's an example usage:

router
  .route("/users/[:id]")
  .link(() => ManagedObjectController<User>(context));

This controller has the following behavior:

Request Action
POST /users Inserts a user into the database with values from the request body
GET /users Fetches all users in the database
GET /users/:id Fetches a single user by id
DELETE /users/:id Deletes a single user by id
PUT /users/:id Updated a single user by id, using values from the request body

The objects returned from getting the collection - e.g, GET /users - can be modified with query parameters. For example, the following request will return the users sorted by their name in ascending order:

GET /users?sortBy=name,asc

The results can be paged (see Paging in Advanced Queries) with query parameters offset, count, pageBy, pageAfter and pagePrior.

A ManagedObjectController<T> can also be subclassed. A subclass allows for callbacks to be overridden to adjust the query before execution, or the results before sending the respond. Each operation - fetch, update, delete, etc. - has a pair of methods to do this. For example, the following subclass alters the query and results before any update via PUT:

class UserController extends ManagedObjectController<User> {
  UserController(ManagedContext context) : super(context);

  Future<Query<User>> willUpdateObjectWithQuery(
      Query<User> query) async {
    query.values.lastUpdatedAt = DateTime.now().toUtc();
    return query;
  }

  Future<Response> didUpdateObject(User object) async {
    object.removePropertyFromBackingMap("private");
    return Response.ok(object);
  }
}

See the chapter on validations, which are powerful when combined with ManagedObjectController<T>.