Manual contentsTutorialsBrowse 103 chapters
Manual 10 min read

Build a Todo JSON:API

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:

suprnova new todo-api
cd todo-api

Step 1: The migration

suprnova make:migration create_todos_table

That writes src/migrations/m<timestamp>_create_todos_table.rs. Replace the body with the schema for todos:

use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(Todos::Table)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(Todos::Id)
                            .big_integer()
                            .not_null()
                            .auto_increment()
                            .primary_key(),
                    )
                    .col(ColumnDef::new(Todos::Title).string().not_null())
                    .col(ColumnDef::new(Todos::Description).text().null())
                    .col(
                        ColumnDef::new(Todos::Done)
                            .boolean()
                            .not_null()
                            .default(false),
                    )
                    .col(
                        ColumnDef::new(Todos::CreatedAt)
                            .timestamp_with_time_zone()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .col(
                        ColumnDef::new(Todos::UpdatedAt)
                            .timestamp_with_time_zone()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(Todos::Table).to_owned())
            .await
    }
}

#[derive(DeriveIden)]
enum Todos {
    Table,
    Id,
    Title,
    Description,
    Done,
    CreatedAt,
    UpdatedAt,
}

Run it:

suprnova migrate

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 chrono::{DateTime, Utc};
use suprnova::model;

#[model(
    table = "todos",
    fillable = ["title", "description", "done"],
    timestamps,
)]
pub struct Todo {
    pub id: i64,
    pub title: String,
    pub description: Option<String>,
    pub done: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

// 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 todo::{ActiveModel, Column, Entity};

Wire the module into src/models/mod.rs:

pub mod todo;

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 suprnova::request;

#[request]
pub struct CreateTodoRequest {
    #[validate(length(min = 1, max = 255, message = "title is required"))]
    pub title: String,

    #[validate(length(max = 1000))]
    pub description: Option<String>,
}

#[request]
pub struct UpdateTodoRequest {
    #[validate(length(min = 1, max = 255))]
    pub title: Option<String>,

    #[validate(length(max = 1000))]
    pub description: Option<String>,

    pub done: Option<bool>,
}

And register it in src/lib.rs:

pub mod requests;

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 crate::models::todo::Todo;
use suprnova::Data;
use validator::Validate;

#[derive(Debug, Clone, Data, Validate)]
#[json_resource("todos")]
pub struct TodoResource {
    pub id: i64,
    pub title: String,
    pub description: Option<String>,
    pub done: bool,
    pub created_at: String,
    pub updated_at: String,
}

impl From<Todo> for TodoResource {
    fn from(t: Todo) -> Self {
        Self {
            id: t.id,
            title: t.title,
            description: t.description,
            done: t.done,
            created_at: t.created_at.to_rfc3339(),
            updated_at: t.updated_at.to_rfc3339(),
        }
    }
}

Wire it in src/resources/mod.rs:

pub mod todo_resource;

And re-declare the module in src/lib.rs:

pub mod resources;

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 - FromParam parses the named route param of the same name. Bad input (/api/todos/abc) short-circuits to 400.
  • CreateTodoRequest / UpdateTodoRequest - FromRequest deserialises 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 crate::models::todo::Todo;
use crate::requests::{CreateTodoRequest, UpdateTodoRequest};
use crate::resources::todo_resource::TodoResource;
use suprnova::{
    attrs, handler, LengthAwarePaginator, Model, Resource, Response,
};

// GET /api/todos?page=2
#[handler]
pub async fn index() -> Response {
    let page = Todo::query()
        .order_by_desc("created_at")
        .paginate(20)
        .await?;
    // Re-pack the paginator around `TodoResource` so the JSON:API
    // renderer sees resource objects, not raw models. The pagination
    // window (`total`, `per_page`, `current_page`) is preserved.
    let total = page.total;
    let per_page = page.per_page;
    let current_page = page.current_page;
    let resources: Vec<TodoResource> =
        page.data.into_iter().map(TodoResource::from).collect();
    let paginator = LengthAwarePaginator::new(resources, total, per_page, current_page)
        .with_path("/api/todos");
    Resource::paginated(paginator).render().await
}

// GET /api/todos/{todo}
#[handler]
pub async fn show(todo: i64) -> Response {
    let todo = Todo::find_or_fail(todo).await?;
    Resource::single(TodoResource::from(todo)).render().await
}

// POST /api/todos
#[handler]
pub async fn store(form: CreateTodoRequest) -> Response {
    let todo = Todo::create(attrs! {
        title: form.title,
        description: form.description,
        done: false,
    })
    .await?;
    Resource::single(TodoResource::from(todo))
        .created()           // 201
        .render()
        .await
}

// PUT /api/todos/{todo}
#[handler]
pub async fn update(todo: i64, form: UpdateTodoRequest) -> Response {
    let row = Todo::find_or_fail(todo).await?;

    let mut changes = attrs!();
    if let Some(title) = form.title {
        changes.insert("title", title.into());
    }
    if let Some(description) = form.description {
        changes.insert("description", description.into());
    }
    if let Some(done) = form.done {
        changes.insert("done", done.into());
    }
    let updated = row.update(changes).await?;
    Resource::single(TodoResource::from(updated)).render().await
}

// DELETE /api/todos/{todo}
#[handler]
pub async fn destroy(todo: i64) -> Response {
    Todo::find_or_fail(todo).await?.delete().await?;
    suprnova::json_response!({ "deleted": true })
}

Wire it in src/controllers/mod.rs:

pub mod todos;

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 crate::controllers::todos;
use suprnova::{delete, get, post, put, routes};

routes! {
    get!("/api/todos",           todos::index   ).name("todos.index"),
    get!("/api/todos/{todo}",    todos::show    ).name("todos.show"),
    post!("/api/todos",          todos::store   ).name("todos.store"),
    put!("/api/todos/{todo}",    todos::update  ).name("todos.update"),
    delete!("/api/todos/{todo}", todos::destroy ).name("todos.destroy"),
}

The routes! macro returns a configured Router that Application::routes(...) consumes at boot.

Step 7: Run it

suprnova serve --backend-only

Create

curl -X POST http://localhost:8765/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Read JSON:API spec", "description": "All of it"}'
{
  "data": {
    "type": "todos",
    "id": "1",
    "attributes": {
      "title": "Read JSON:API spec",
      "description": "All of it",
      "done": false,
      "created_at": "2026-05-30T12:00:00+00:00",
      "updated_at": "2026-05-30T12:00:00+00:00"
    }
  }
}

List (paginated)

curl http://localhost:8765/api/todos
{
  "data": [
    { "type": "todos", "id": "1", "attributes": {  } }
  ],
  "meta": {
    "pagination": {
      "total": 1,
      "per_page": 20,
      "current_page": 1,
      "last_page": 1
    }
  },
  "links": {
    "first": "?page=1",
    "last":  "?page=1",
    "prev":  null,
    "next":  null
  }
}

Sparse fieldsets

curl 'http://localhost:8765/api/todos/1?fields[todos]=title,done'
{
  "data": {
    "type": "todos",
    "id": "1",
    "attributes": { "title": "Read JSON:API spec", "done": false }
  }
}

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

curl -X PUT http://localhost:8765/api/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"done": true}'

A partial body works because every field in UpdateTodoRequest is Option<T> - the handler only writes the keys that arrived.

Delete

curl -X DELETE http://localhost:8765/api/todos/1
# {"deleted": true}

Validation failure

curl -X POST http://localhost:8765/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": ""}'
{
  "message": "The given data was invalid.",
  "errors": { "title": ["title is required"] },
  "request_id": "8f9e1a2b-…"
}

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 - FormRequest trait, content-type dispatch, authorize(&Request)
  • Controllers - what #[handler] extracts and how route model binding works under the hood