A Suprnova controller is just an async function. It takes whatever it
needs from the request - typed path parameters, a loaded model, a
validated form - and returns a Response. There is no controller base
class. There is no service-locator wiring file. The function is the
unit, and the #[handler] attribute glues it to the routing macros.
use ;
use crateUser;
// GET /users/{user}
pub async
That handler's signature does three things at once: declares the route
parameter (user), pulls the row out of the database, and 404s if the
row isn't there. None of it is written by hand. #[handler] reads the
argument types and generates the extraction.
Generating a controller
This writes src/controllers/user.rs with a single invoke stub and
adds pub mod user; to src/controllers/mod.rs. The stub is the
minimum-viable handler:
//! User controller
use ;
pub async
Add as many functions to the file as you want - Suprnova doesn't track
controller "classes", just functions. Many apps split by resource
(controllers::user::{index, show, store, update, destroy}), but
nothing in the framework enforces it.
The name is converted to snake_case for the filename: OrderItem
becomes order_item.rs.
The #[handler] attribute
The macro generates the matching extractor for each parameter. Four categories:
| Parameter type | Extracted via | Failure mode |
|---|---|---|
Request |
passes the request through unchanged | - |
i32, i64, u32, u64, usize, String |
FromParam - parses the route param of the same name |
400 on parse failure, 400 on missing |
T: RouteBinding (every #[suprnova::model] struct) |
binds the route param of the same name, matching the model's route key | 404 if the value does not parse or matches no row |
Anything else (T: FromRequest) |
calls T::from_request(req) - typically a #[derive(FormRequest)] validator |
whatever from_request returns; 422 for validation errors |
The macro tells a path value and Request by their spelling. For any
other type the generated code chooses at compile time: a type that
implements RouteBinding binds, and any other reads the body. A generic
argument always reads the body.
The router binds every bound argument first, in path order, after the
route's middleware and before the handler, so a missing row is a 404
before the form is read. The other arguments are extracted in
declaration order. If any extraction fails, the error short-circuits via
? and the handler body never runs. A handler with #[authorize] changes
the order; see Authorization. One argument at most may
read the request body; the router refuses a handler with two at startup.
Path parameters
// Route: get!("/users/{id}", controllers::user::show)
pub async
// Route: get!("/posts/{post_id}/comments/{comment_id}", show_comment)
pub async
The argument name must match the route placeholder: {id} requires
id: …. The argument type is parsed via FromParam. Bad input
(/users/abc against id: i64) returns 400 with a message naming
the parameter and target type.
Route model binding
Every #[suprnova::model] struct implements RouteBinding. Declare the
model as an argument and the router loads it:
use ;
use crateUser;
// Route: get!("/users/{user}", controllers::user::show)
pub async
The route placeholder name ({user}) and the argument name (user)
must match; the router refuses a route whose handler reads a parameter
its path does not declare. The value is parsed as the model's route key
and looked up through User::query(), so global scopes and the
soft-delete filter apply. A value that does not parse or matches no row
answers 404. For binding fields, scoped children, custom resolution and
enums, see Route model binding.
Form requests
Anything that implements FromRequest plugs in the same way. The
common case is a #[derive(FormRequest)] struct that validates the
request body and surfaces a 422 with field-keyed errors on failure:
use ;
use crateUser;
use crateUpdateUserRequest;
// Route: put!("/users/{user}", controllers::user::update)
pub async
See Form Requests for the validator derive and the full validation pipeline.
When you want the raw Request
If you'd rather extract things by hand - or you need a header, a
cookie, a query string - take Request directly:
use ;
pub async
You can mix and match: pub async fn nested(category_id: i64, product: Product, req: Request) is a valid signature. The macro extracts each argument by its own rule.
Authorization
#[authorize] declares the gate check a handler needs. The check runs
after the route parameters are bound and before the request body is read,
so a denied request never reaches validation or the handler body:
use ;
use cratePost;
use crateUpdatePostRequest;
// Route: put!("/posts/{post}", controllers::post::update)
pub async
A guest gets 401, a denial 403, and a policy that denies as not found 404.
On a handler with #[authorize], the route-bound arguments and the path
values come first, then the checks run, then the macro extracts the form
request or Request, whatever order the signature lists them in. For the type form,
several attributes on one handler, and the full rules, see
Authorize a handler.
Handlers inside an impl block
To group a controller's handlers on a type, put them in an impl block and
name the type in the attribute, #[handler(Self = Posts)]:
use ;
use cratePost;
;
The handler takes every argument a free handler takes, #[authorize]
included, and the router checks it at startup as it checks a free one. A
handler in an impl block has no self argument. Without Self = Posts,
the build fails and says to add it. If the attribute names another type
than the block's, the build fails too.
The attribute needs the type because #[handler] records each handler
for the router's startup checks, and that record is generated outside the
function, where Self does not name the block's type.
The Response contract
Response is an alias for Result<HttpResponse, HttpResponse>. Both
arms carry the same payload type, which is why ? works everywhere.
The middleware chain collapses the result with one line at the
boundary:
result.unwrap_or_else
This is the same contract every ? propagation point relies on.
Errors get converted via From<FrameworkError> for HttpResponse
before they reach the chain - see Error Model for
the full picture.
The body of a handler reads top-to-bottom and uses ? to bail:
use ;
use crateUser;
pub async
If find_or_fail returns Err, the function exits with a 404. If
invoices().get() errors, you get a 500. No match statements, no
exception handlers.
Creating responses
Three macros and a builder cover the common cases:
use ;
pub async
pub async
pub async
pub async
json_response!, text_response!, and HttpResponse::* all produce
the same Response type. The ResponseExt trait adds .status(...),
.header(...), .cookie(...), and .with_headers(...) so you can
chain configuration onto a macro result.
For everything else - file downloads, streaming bodies, Inertia responses, redirects - see Responses.
Redirects
redirect!("route.name") validates the route exists at compile time
and returns a builder you can chain configuration onto:
use ;
pub async
pub async
pub async
.with(key, value) fills a route placeholder; .query(key, value)
appends a query string parameter; .flash(key, value) writes to the
session flash bag for the next request. .into() converts the
builder to a Response.
If the named route doesn't exist, the macro fails the compile with a list of available route names - typos surface before staging.
Container-injected services
Resolve services from the container with App::resolve (concrete
types) or App::resolve_make (trait objects). Both return
Result<_, FrameworkError> so they compose with ?:
use ;
use crateUserService;
pub async
If you're binding actions with #[injectable], this is how a
controller calls them. See Actions for the action
shape, and Service Container for the full container
surface - binding, factories, the task-local / thread-local /
global lookup cascade.
A worked RESTful controller
// src/controllers/user.rs
use ;
use crateUser;
use crate;
// GET /users
pub async
// GET /users/{user}
pub async
// POST /users
pub async
// PUT /users/{user}
pub async
// DELETE /users/{user}
pub async
Register them with the routes! macro:
// src/routes.rs
use ;
use cratecontrollers;
routes!
The route placeholder {user} matches the argument name user: User, which is how the framework knows which path segment loads the model.
Controller middleware
A resource controller can declare the middleware its actions run, as
Laravel's HasMiddleware does. Each entry is a ControllerMiddleware:
a middleware value, or the name of an alias or group, scoped to some
actions with only or except.
For a module you register with resource!, declare a
pub fn middleware():
// src/controllers/posts.rs
use ;
use crateAuditMiddleware;
// index, create, store, show, edit, update and destroy follow, as
// #[handler] functions.
resource!("posts", controllers::posts) finds the function and runs each
middleware on the actions it is scoped to. A module without the function
runs none. For a ResourceController, override its middleware method:
use ;
The scoping rules are Laravel's:
- An action runs the middleware when
onlylists it, oronlywas never called, andexceptdoes not list it.only(&[])therefore runs it on no action. updatecoversPUTandPATCHalike.- The middleware runs after the middleware of the group around the
resource and after the middleware you give the resource when you
register it, as Laravel's
gatherMiddlewareappends the controller's middleware last. A name a group already gave the route runs once, in the group's place.
Suprnova resolves each name when the resource registers, which is at
boot. A name that no alias or group carries fails the registration:
try_register returns the error, and register panics. To scope
middleware to some actions at the registration site instead, see
Resource routing.
The Request API
The methods you'll reach for most often when taking Request directly:
| Method | Returns | Notes |
|---|---|---|
method() |
&hyper::Method |
HTTP method |
path() |
&str |
URL path |
param(name) |
Result<&str, ParamError> |
route param; ? to bail |
params() |
&HashMap<String, String> |
all route params |
query() |
Option<&str> |
raw query string |
query_param(key) |
Option<String> |
single query string value |
query_params() |
HashMap<String, String> |
all query params |
query_into::<T>() |
Result<T, FrameworkError> |
typed deserialize |
header(name) |
Option<&str> |
single header |
headers() |
&hyper::HeaderMap |
full header map |
has_header(name) |
bool |
presence check |
bearer_token() |
Option<String> |
parsed Authorization: Bearer … |
cookie(name) |
Option<String> |
single cookie value |
cookies() |
HashMap<String, String> |
all cookies |
ip() |
Option<String> |
client IP: the peer, or the client a trusted proxy names in X-Forwarded-For |
secure() |
bool |
HTTPS detection (incl. proxies) |
is_method(m) |
bool |
case-insensitive |
is_inertia() |
bool |
Inertia XHR header |
ajax() |
bool |
X-Requested-With: XMLHttpRequest |
expects_json() / wants_json() |
bool |
Accept-header inspection |
route_name() |
Option<String> |
matched route's .name(...) |
json::<T>() |
Result<T, FrameworkError> |
parse body as JSON (consumes) |
form::<T>() |
Result<T, FrameworkError> |
parse as form-urlencoded |
input::<T>() |
Result<T, FrameworkError> |
content-type-dispatched parse |
This is a Laravel-shaped surface - every method here mirrors a method
on Laravel's Request class.
File layout
Convention:
src/
├── controllers/
│ ├── mod.rs # pub mod home; pub mod user; ...
│ ├── home.rs
│ ├── user.rs
│ └── api/
│ ├── mod.rs
│ └── user.rs
├── routes.rs # routes! { ... }
└── main.rs
Nothing in the framework enforces this layout - controllers can live
anywhere reachable from routes.rs. The convention exists because
it's what scaffolding emits and because routes/controllers are the
natural pair.
Why Suprnova diverges
Laravel controllers are classes that extend Illuminate\Routing\Controller.
Methods are called on instances the container resolves per-request,
which is where constructor-injection happens. The pattern is fine on
PHP - new-on-every-request is cheap when the entire process tears
down after the response.
In Rust, that pattern would mean either (a) allocating a controller
struct per request, which costs an Arc clone you don't need, or (b)
re-implementing dependency injection through a base class hierarchy
that doesn't pay for itself.
Suprnova picks the simpler model: a controller is a free async
function, and "dependencies" are either container resolutions
(App::resolve::<Service>()?) or extraction-typed arguments
(form: UpdateUserRequest). Constructor injection happens at the
#[injectable] boundary in Actions, where it belongs.
The handler stays a pure function from request to response, which
makes it trivial to test in isolation: build a Request, call the
function, assert on the result.
Controller middleware follows from that. Laravel reads
HasMiddleware::middleware() from the class of the route's action. A
Suprnova controller is a module, so a resource reads the module's
pub fn middleware(), or the middleware method of a
ResourceController. Only a resource reads it: a function you route by
itself with get! or inside group!(..., controller = ...) takes the
middleware its route and group give it. Suprnova also resolves every
named middleware at boot, where Laravel resolves it on the request, so a
misspelt name stops the server from starting instead of failing a
request.
Next
- Routing - what
routes!,get!,post!, and.name()expand into - Form Requests - typed validation via
#[derive(FormRequest)] - Responses - JSON, HTML, files, streams, Inertia pages, redirects
- Service Container - what
App::resolveactually does - Actions - where business logic lives outside the controller
- Error Model - how
?turnsFrameworkErrorinto a response
