A walk-through of the API path end-to-end: migration, model, validated
form requests, route model binding, JSON:API resource envelopes,
sparse fieldsets, pagination. By the end you have a five-endpoint
todo service that emits spec-conformant
JSON:API responses with ?include= and
?fields[todos]=... honoured automatically.
What you'll build:
| Method | Route | Action |
|---|---|---|
GET |
/api/todos |
list (paginated) |
GET |
/api/todos/{todo} |
show |
POST |
/api/todos |
create |
PUT |
/api/todos/{todo} |
update |
DELETE |
/api/todos/{todo} |
delete |
Prerequisites
A scaffolded project:
Step 1: The migration
That writes src/migrations/m<timestamp>_create_todos_table.rs.
Replace the body with the schema for todos:
use *;
;
Run it:
The down body lets migrate:rollback reverse the change later.
Step 2: The model
A #[suprnova::model] struct is the Eloquent model - the macro
emits the SeaORM Entity, Column, and ActiveModel in an inner
module and gives the struct the query surface (Todo::query(),
Todo::find, Todo::create, model.update, model.delete,
auto-managed timestamps, lifecycle events). Create src/models/todo.rs:
use ;
use model;
// Re-export the SeaORM types the macro emits in the inner `todo`
// module so call sites can reach for them without poking at the macro
// internals.
pub use ;
Wire the module into src/models/mod.rs:
The fillable list is the mass-assignment allowlist - only those
fields can be set via Todo::create(attrs!{...}) and
model.update(attrs!{...}). Fields outside the list are guarded
against accidental writes from request input.
Step 3: The form requests
Validation lives on a #[request] struct. extract() runs the
validator before the handler body sees the value; a failure short-
circuits to a 422 with the Laravel/Inertia error bag. Create
src/requests.rs:
use request;
And register it in src/lib.rs:
The #[request] attribute expands to the equivalent of
#[derive(serde::Deserialize, validator::Validate)] + impl FormRequest,
so the struct fields are also the input schema. Optional fields
(Option<T>) are the right shape for partial updates: a missing key
in the JSON body deserialises to None, and the handler treats
None as "don't change this column".
Step 4: The JSON:API resource
A resource is a #[derive(Data)] struct with #[json_resource("type")].
The macro emits the IntoJsonResource impl that Resource::single,
Resource::collection, and Resource::paginated consume. The
resource's fields become the JSON:API attributes object - every
sparse-fieldset filter and ?include= chain dispatches through this
type. Create src/resources/todo_resource.rs:
use crateTodo;
use Data;
use Validate;
Wire it in src/resources/mod.rs:
And re-declare the module in src/lib.rs:
The id field supplies the JSON:API id member (stringified per
spec); every other field lands in attributes and is subject to
sparse-fieldset filtering - a request that names
?fields[todos]=title,done gets back only those two attributes,
without any handler-side work.
Step 5: The controller
The #[handler] attribute classifies each parameter and generates
the matching extractor:
i64-FromParamparses the named route param of the same name. Bad input (/api/todos/abc) short-circuits to 400.CreateTodoRequest/UpdateTodoRequest-FromRequestdeserialises the body, runs validation, and 422s on failure.Request- passed through unchanged.
Loading the row goes through the Eloquent surface: Todo::find_or_fail(id)
returns a 404 when no row matches.
Create src/controllers/todos.rs:
use crateTodo;
use crate;
use crateTodoResource;
use ;
// GET /api/todos?page=2
pub async
// GET /api/todos/{todo}
pub async
// POST /api/todos
pub async
// PUT /api/todos/{todo}
pub async
// DELETE /api/todos/{todo}
pub async
Wire it in src/controllers/mod.rs:
The argument name must match the route placeholder - {todo} maps
to todo: i64. The macro parses the path segment via FromParam,
and the handler body then drives the Eloquent surface to load,
update, and delete the row.
Step 6: The routes
src/routes.rs:
use cratetodos;
use ;
routes!
The routes! macro returns a configured Router that
Application::routes(...) consumes at boot.
Step 7: Run it
Create
List (paginated)
Sparse fieldsets
The IncludeMiddleware parses ?fields[type]=..., binds the filter
to a task-local, and Resource::single reads it during render -
the handler doesn't see the query parameter at all.
Update
A partial body works because every field in UpdateTodoRequest is
Option<T> - the handler only writes the keys that arrived.
Delete
# {"deleted": true}
Validation failure
422 with the Laravel/Inertia error bag - the handler body never ran.
Where each piece lives
| File | Role |
|---|---|
src/migrations/m*_create_todos_table.rs |
schema |
src/models/todo.rs |
#[suprnova::model] struct |
src/requests.rs |
#[request] form requests, validated by extract() |
src/resources/todo_resource.rs |
#[derive(Data)] + #[json_resource("todos")] |
src/controllers/todos.rs |
#[handler] functions |
src/routes.rs |
routes! registrations |
Next
- Eloquent - the full Model surface, query builder,
attrs!, lifecycle events, soft deletes, relationships - Validation -
#[request],validate!,Unique, async hooks, cross-field rules - JSON:API Resources -
?include=chains, per-resource links/meta,Maybe<T>conditional attributes - Form Requests -
FormRequesttrait, content-type dispatch,authorize(&Request) - Controllers - what
#[handler]extracts and how route model binding works under the hood
