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.
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).Image- rejects parts whose magic bytes don't claimimage/*.MimeType<L>- accepts a fixed allowlist provided by your ownMimeAllowlisttype.()- no-op;UploadedFile<()>accepts any bytes.
Validators compose as tuples: (Image, 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> |
X-Forwarded-For[0] → X-Real-IP → peer addr. |
req.ips() |
Vec<String> |
Full chain: proxy headers, then peer addr. |
req.user_agent() |
Option<&str> |
User-Agent header. |
req.port() |
Option<u16> |
Host header port → X-Forwarded-Port → URI port. |
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
