lilydocs
HTTP APIWebSocketConsumer
HTTP3 min read

Controllers and routes

Define HTTP methods, path parameters, and route matching behavior.

A controller groups related endpoints under a common base path. Lily discovers controllers at link time and creates them during application setup.

Controller contract#

Use #[derive(Controller)], a single #[base_path], ControllerTrait::new, and #[controller] impl together. Actions must be async and receive &self. Put helper methods in a separate, ordinary impl block.

Parameterized routes and creation#

requests.rs
use std::sync::Arc;
use lilyrs::http_api::{
    controller, Controller, ControllerInitError, ControllerTrait, Created,
    Extensions, HttpApiError, Json, Path, Query,
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct UserPath { id: u64 }
#[derive(Deserialize)]
struct UserQuery { language: Option<String> }
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CreateUser { name: String }
#[derive(Serialize)]
struct UserView { id: u64, name: String }

#[derive(Controller)]
#[base_path("/users")]
struct UsersController;

#[lilyrs::http_api::async_trait::async_trait]
impl ControllerTrait for UsersController {
    async fn new(_: Arc<Extensions>) -> Result<Self, ControllerInitError> { Ok(Self) }
}

#[controller]
impl UsersController {
    #[get("/:id")]
    async fn read(&self, Path(path): Path<UserPath>, Query(query): Query<UserQuery>)
        -> Result<UserView, HttpApiError>
    {
        Ok(UserView { id: path.id, name: query.language.unwrap_or_else(|| "tr".into()) })
    }

    #[post("/")]
    async fn create(&self, Json(input): Json<CreateUser>)
        -> Result<Created<UserView>, HttpApiError>
    {
        if input.name.trim().is_empty() {
            return Err(HttpApiError::ValidationError("name is required".into()));
        }
        // Teaching example: in a real application, the domain service produces this result.
        Ok(Created::new("/users/42", UserView { id: 42, name: input.name }))
    }
}

GET /users/42?language=tr separates parameters into typed DTOs. A successful POST /users returns 201 and Location: /users/42. This example does not persist a record; it demonstrates the HTTP contract. Move actual persistence into a domain service received through DI.

When the controller base path is /users and the action path is /, the combined route is /users; a trailing slash is not added automatically. Send requests to the registered path.

Route syntax#

Path Matches
/users/me A static path
/users/:id A single-segment parameter
/files/*path The remaining trailing path

A catch-all must be the last segment. Static routes take precedence over parameterized routes. Duplicate method/path pairs or ambiguous parameter paths with equal precedence cause a build error.

get, post, put, patch, delete, head, and options are built-in attributes. For a custom method, use #[route(method = "CUSTOM", path = "/...")]. With OpenAPI enabled, exclude a nonstandard method using #[openapi(skip)].

HEAD, 404, and 405#

If there is no explicit HEAD route, routing falls back to GET; the transport does not send the response body. A matching path with no matching method produces 405 and an Allow header. A path that does not match produces 404.

Sources for this page v0.1.0

This guide is based on the reviewed framework commit.

QUICK START
Documentation
Source code