Suprnova handlers receive a Request - the wire-level HTTP request - or
a typed form-request struct that parses, validates, and authorizes the
body before your code runs. Both paths live on the same #[handler]
macro; you pick the shape per route. This chapter covers both, plus the
multipart upload extractor and the raw accessors you reach for in
middleware.
Typed form requests
The #[request] attribute marks a struct as a FormRequest. The macro
adds serde::Deserialize and validator::Validate derives and emits an
impl FormRequest so the #[handler] macro knows to extract and
validate it on the way in:
use request;
A handler that names this type as its parameter is handed an already-validated value:
use ;
use crateCreateUserRequest;
pub async
A handler that names Request instead gets the raw request through
unchanged:
use ;
pub async
Both are extractors - the #[handler] macro looks up
FromRequest::from_request for every parameter type, and any struct
that implements FormRequest gets a blanket FromRequest impl for
free.
Validation rules
Validation runs through the validator crate. Common rules:
String validations
use LazyLock;
use Regex;
// validator 0.20 implements `AsRegex` for `std::sync::LazyLock<Regex>`
// but not for `once_cell::sync::Lazy<Regex>` - use the std type so the
// derive's `#[validate(regex(path = "..."))]` expansion typechecks.
static PHONE_REGEX: =
new;
Numeric validations
Nested and collection validations
use Deserialize;
Common validation attributes
| Attribute | Description | Example |
|---|---|---|
email |
Valid email format | #[validate(email)] |
url |
Valid URL format | #[validate(url)] |
length |
String/collection length | #[validate(length(min = 1, max = 100))] |
range |
Numeric range | #[validate(range(min = 0, max = 100))] |
regex |
Regex pattern match | #[validate(regex(path = "PATTERN"))] |
contains |
String contains substring | #[validate(contains(pattern = "@"))] |
does_not_contain |
String doesn't contain | #[validate(does_not_contain(pattern = "admin"))] |
nested |
Validate nested struct | #[validate(nested)] |
Validation error responses
When validation fails, Suprnova returns a 422 response with the Laravel / Inertia-compatible error bag:
HTTP 422 Unprocessable Entity
The errors shape matches what @inertiajs/* clients read from
usePage().props.errors directly.
Nested fields
A #[validate(nested)] failure is reported under a dotted key naming the
full path, the same notation Laravel uses. A nested struct contributes
parent.field; an element of a validated Vec<T> contributes
parent.<index>.field:
Index 1 is the second element - the first element passed and is absent
from the bag. Bind the key straight through on the client:
form.errors['items.1.name'].
Complete example
A user registration endpoint, end to end.
Define the request:
// src/requests/create_user.rs
use request;
Create the controller:
// src/controllers/user.rs
use ;
use crateCreateUserRequest;
pub async
pub async
Register the routes:
// src/routes.rs
use ;
use cratecontrollers;
routes!
Authorization and cross-field hooks
The FormRequest trait exposes three lifecycle hooks: authorize,
after_validation, and after_validation_async. Both the #[request]
attribute and the #[derive(FormRequestDerive)] form emit a default
impl FormRequest for you. To override any hook, add the
#[form_request(custom_hooks)] opt-out to suppress the default impl,
then write your own. (This mirrors the #[multipart(custom_hooks)]
pattern.)
use ;
use Deserialize;
use Validate;
The opt-out also works under the #[request] attribute form - useful
when you want the attribute's auto-derives but need to override hooks:
use ;
When authorize returns false, extraction returns
FrameworkError::Unauthorized and renders:
HTTP 403 Forbidden
after_validation is the synchronous cross-field hook - use it for
rules like "password and confirmation must match". after_validation_async
is the asynchronous counterpart and is where database-backed rules
(e.g. the built-in Unique) participate in automatic validation. Both
fire after the per-field validator rules pass; extract bails at the
first failing stage.
use ;
use Deserialize;
use Validate;
Body size caps
The per-struct #[form_request(max_body_bytes = N)] attribute
overrides the process-global 8 MiB cap on a single FormRequest:
use FormRequestDerive;
use Deserialize;
use Validate;
// 64 MiB
Content-Length is parsed up front and the request is rejected with
HTTP 413 before a body byte is read when the declared size exceeds
the cap; clients that lie about Content-Length still trip the
streaming byte counter during read.
Content type detection
FormRequest::extract looks only at the Content-Type header:
application/x-www-form-urlencoded→ parsed viaserde_urlencodedapplication/jsonor anyapplication/*+jsonsuffix → parsed viaserde_json- Anything else (including a missing header) → rejected with HTTP 415 Unsupported Media Type, before the body is read
For multipart bodies (multipart/form-data), see
file uploads below.
Reading the body directly
For one-off endpoints or middleware that doesn't want a full
FormRequest, the Request type itself reads the body in three flavors -
each consumes self because the body can be read at most once:
use Deserialize;
use ;
pub async
pub async
pub async
For raw access, req.body_bytes().await returns the buffered Bytes
plus the RequestParts metadata (route params and content type). Use
body_bytes_with_cap(n) to override the global 8 MiB cap on a
case-by-case basis.
Resolving services alongside the form
Validated form requests compose with the service container.
Use App::resolve::<T>() (or App::get::<T>()) inside the handler:
use ;
use crateCreateUserRequest;
use crateUserService;
pub async
File uploads (MultipartRequest)
multipart/form-data is its own extractor - #[derive(MultipartRequest)]
streams the body part by part, spilling large file parts to a temp file
above the configured threshold so a 200 MiB upload never sits fully in
RAM. Each field carries a #[field("name")] annotation that names the
wire field; file fields use UploadedFile<V> where V is a validator
(or a tuple of validators) from suprnova::http::upload::validators.
use ;
use UploadedFile;
use ;
pub async
Field shapes:
| Declaration | Wire shape |
|---|---|
UploadedFile<V> |
required file |
Option<UploadedFile<V>> |
optional file |
Vec<UploadedFile<V>> |
array uploads (photos[]) |
String / u32 / any FromStr |
text field (required) |
Option<String> / Option<T: FromStr> |
optional text field |
Vec<String> / Vec<T: FromStr> |
repeated text fields |
Built-in validators in suprnova::http::upload::validators:
MaxSize<N>- short-circuits at the byte boundary when the running total exceedsNbytes (HTTP 413).ImageFile- rejects parts whose magic bytes don't claimimage/*. (Named after Laravel's own rule; the plainImagename belongs to the image-manipulation pipeline - see Images.)MimeType<L>- accepts a fixed allowlist provided by your ownMimeAllowlisttype.()- no-op;UploadedFile<()>accepts any bytes.
Validators compose as tuples: (ImageFile, MaxSize<5_242_880>) runs both,
short-circuiting on the first failure.
Per-field caps and array bounds
The byte cap on the total body is global (8 MiB by default for
multipart, configurable via
suprnova::http::upload::set_global_max_multipart_body_bytes). Per-field
caps prevent abuse where a body of many small parts grows
Vec<UploadedFile<_>> unbounded within the byte budget:
The (max_count + 1)-th part with that name returns HTTP 422 before
allocating, so the extra part never reaches Vec growth.
Authorize and after-validation hooks
MultipartRequest mirrors FormRequest's hooks via the
MultipartRequestHooks trait. By default the derive emits an empty
impl; opt in to your own with #[multipart(custom_hooks)]:
use ;
use ;
Streaming to storage
UploadedFile::store_as writes the part to a registered storage disk.
For disk-backed parts the path is fully streaming (64 KiB chunks via
opendal::Operator::writer); in-memory parts use a single write call.
Use the content-derived extension when the storage path is
content-addressed - the filename header is untrusted:
use Storage;
let disk = disk?;
let path = format!;
form.avatar.store_as.await?;
See Filesystem for the storage disk registry.
File organization
The standard structure for requests:
src/
├── requests/
│ ├── mod.rs # Re-exports all requests
│ ├── create_user.rs # CreateUserRequest
│ ├── update_user.rs # UpdateUserRequest
│ └── create_post.rs # CreatePostRequest
├── controllers/
│ └── user.rs # Uses CreateUserRequest
└── routes.rs
src/requests/mod.rs:
pub use CreateUserRequest;
pub use UpdateUserRequest;
End-to-end type safety with Inertia
Requests can also derive InertiaProps to generate TypeScript types, enabling end-to-end type safety from your Rust backend to your React frontend.
Generating TypeScript types for requests
Add InertiaProps derive alongside #[request]:
use ;
Run type generation:
This generates TypeScript types in frontend/src/types/inertia-props.ts:
export interface CreateTodoRequest {
title: string
description: string | null
}
Type-safe forms with Inertia
Use Inertia's <Form> component for the cleanest form handling:
import { Form, usePage } from '@inertiajs/react'
export default function CreateTodo() {
const { errors } = usePage().props
return (
<Form action="/todos" method="post">
<input
type="text"
name="title"
placeholder="Todo title"
/>
{errors?.title && <span className="error">{errors.title}</span>}
<textarea
name="description"
placeholder="Description (optional)"
/>
<button type="submit">Create Todo</button>
</Form>
)
}
For more control, combine <Form> with the useForm hook and your generated types:
import { Form, useForm } from '@inertiajs/react'
import type { CreateTodoRequest } from '../types/inertia-props'
export default function CreateTodo() {
const { data, setData, errors, processing } = useForm<CreateTodoRequest>({
title: '',
description: null,
})
return (
<Form action="/todos" method="post">
{({ processing }) => (
<>
<input
type="text"
name="title"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
placeholder="Todo title"
/>
{errors.title && <span className="error">{errors.title}</span>}
<textarea
name="description"
value={data.description || ''}
onChange={(e) => setData('description', e.target.value || null)}
placeholder="Description (optional)"
/>
<button type="submit" disabled={processing}>
Create Todo
</button>
</>
)}
</Form>
)
}
What the derive buys you
- TypeScript catches field-name typos and type mismatches at compile time.
- IDE autocomplete reads the generated
.tsdirectly. - Rename a field in Rust, rerun
suprnova generate-types, and the TypeScript surface follows.
See TypeScript types for the full generation pipeline.
Request accessors
Beyond the validated-form pattern above, the Request type carries Laravel-style accessors for inspecting the wire-level request - URL, headers, query string, content negotiation, route metadata, and client IP. These are useful in middleware, in handlers that want raw access alongside a FormRequest, and in any place where validated parsing isn't the right tool.
URL and path
| Method | Returns | Notes |
|---|---|---|
req.path() |
&str |
Raw URI path. |
req.decoded_path() |
String |
Path with percent-escapes resolved. |
req.segments() |
Vec<String> |
Path split on /, empty segments dropped. |
req.segment(index, default) |
Option<String> |
1-based segment access. |
req.url() |
String |
Scheme + host + path (no query string). |
req.full_url() |
String |
URL + query string. |
req.full_url_with_query(&[("k","v")]) |
String |
Append or override query keys. |
req.full_url_without_query(&["k"]) |
String |
Strip query keys. |
use ;
pub async
Host, scheme, IP
| Method | Returns | Source order |
|---|---|---|
req.host() |
Option<String> |
X-Forwarded-Host → Host → URI authority. |
req.http_host() |
Option<String> |
Host plus port when non-default. |
req.scheme_and_http_host() |
Option<String> |
scheme://host:port. |
req.scheme() |
&'static str |
"https" when [secure] is true, else "http". |
req.secure() |
bool |
URI scheme → X-Forwarded-Proto → X-Forwarded-Ssl: on. |
req.ip() |
Option<String> |
From a trusted proxy: X-Forwarded-For, read from the right, then X-Real-IP when there is no X-Forwarded-For. Otherwise the peer address. |
req.ips() |
Vec<String> |
Every address the request names: X-Forwarded-For left to right, X-Real-IP, then the peer address. For logs only. |
req.user_agent() |
Option<&str> |
User-Agent header. |
req.port() |
Option<u16> |
Host header port → X-Forwarded-Port → URI port. |
req.ip() is the address to key a limit on or to check against an
allowlist. It reads the proxy headers only when the TCP peer is a trusted
proxy, and it returns the peer address in every other case:
- When the peer is not in the trusted proxies,
req.ip()is the peer address. The headers are ignored, because any client can send them. - When the peer is a trusted proxy and the request has an
X-Forwarded-Forheader,req.ip()reads the list from the right. It skips every entry that is a trusted proxy and returns the first entry that is not. A proxy adds the address it saw to the right end of the header, so the left end is what the client wrote and the client chooses it. The header may arrive as several lines. They count as one list, in order. - When the peer is a trusted proxy and the request has no
X-Forwarded-For,req.ip()readsX-Real-IP. With both headers present,X-Real-IPis not read. - An entry that is not an address ends the walk, and
req.ip()returns the proxy that wrote that entry. An entry with a port, such as203.0.113.5:54321, is read as its address.
Three rules keep this safe:
- List every proxy of the chain. A proxy that is not in the trusted proxies is taken for the client, and all of its clients share its address.
- List proxies and nothing else.
APP_TRUSTED_PROXIEStakes addresses and CIDR ranges, such as10.0.0.5,173.245.48.0/20,2400:cb00::/32. A client that connects from a listed range is believed like a proxy and chooses its own address. A range of/0, which is every address, is refused, and boot fails. - Let the proxy write the header. A trusted proxy has to add to
X-Forwarded-Foror replace it. A proxy that passes the client's header on unchanged lets the client write all of it. A proxy that setsX-Real-IPhas to remove anyX-Forwarded-Forfrom the request.
The Forwarded header of RFC 7239 is not read. See
Rate Limiting for
what this means for per-address limits and
Environment Variables
for the variable.
Headers and method
| Method | Returns |
|---|---|
req.has_header("X-Foo") |
bool |
req.bearer_token() |
Option<String> (last Bearer substring, comma-trimmed) |
req.is_method("POST") |
bool (case-insensitive) |
req.ajax() |
X-Requested-With: XMLHttpRequest |
req.pjax() |
Truthy X-PJAX header |
req.prefetch() |
X-Moz, Purpose, or Sec-Purpose = prefetch |
Content negotiation
if req.is_json
if req.expects_json
if req.wants_json
if req.accepts_html
let preferred = req.prefers;
let acceptable = req.acceptable_content_types;
accepts(&[ty]) matches both bare types and application/<vendor>+json-style suffixes. accepts_any_content_type() returns true when there is no Accept header or the top preference is */*.
Query string
let id: = req.query_param;
let present: bool = req.has_query;
let map = req.query_params; // HashMap<String, String>
// Typed query parse via serde
let q: SearchQuery = req.query_into?;
Route metadata
After the router dispatches a request, the matched pattern is recorded on the request:
if req.route_is
let pattern = req.route_pattern; // Some("/users/{id}")
let name = req.route_name; // Some("users.show")
route_is(&[...]) accepts * wildcards (Laravel's Str::is semantics).
Aborting early
For early-exit error handling without the full Response envelope, the abort_with / abort_if / abort_unless helpers return a FrameworkError that renders through the standard From<FrameworkError> for HttpResponse pipeline. They compose with ? directly:
use ;
pub async
abort_if / abort_unless return Ok(()) when the condition is false, so the ? continues normally.
Why Suprnova diverges
Laravel exposes a synchronous, merged input bag - $req->input('field'),
$req->all(), $req->only(['a','b']), $req->boolean('flag') - pulled
from the query string and the parsed body together. Suprnova does not
ship that surface. The reason:
- Suprnova's body is consume-once and async. A synchronous
all()would require buffering every body up front to satisfy a method that most handlers never call - the memory and DoS surface differs from PHP's per-request-process lifecycle. - The typed alternative (
#[request]+FormRequest) gives compile-time field names, validation, and content-type-aware parsing - exactly the safety net the untyped bag lacks.
For query / header / route inspection, reach for query_param,
query_into, has_query, bearer_token, and the header readers
above. For body-side access, define a #[request] struct or a
#[derive(MultipartRequest)] extractor.
Next
- Validation - the rule library behind
#[validate(...)]and the shape of the 422 error bag - Responses - building
HttpResponsevalues back from your handler, including streaming and redirects - Errors - handler patterns built on top of
ResponsebeingResult<HttpResponse, HttpResponse> - Routing - registering routes and the
{id}parametersreq.param("id")reads - Authentication -
Auth::user_as,Auth::attempt, and the guards that resolve the current user from the request - Filesystem - registering the storage disks that
UploadedFile::store_aswrites to
