Manual contentsEloquent ORMBrowse 113 chapters
Manual 10 min read

JSON:API resources

Suprnova ships a JSON:API resource layer for typed REST APIs. Mark a #[derive(Data)] struct with #[json_resource("type")] and the framework emits an IntoJsonResource impl that handles single envelopes, collections, paginated collections, sparse fieldsets (?fields[type]=...), compound included documents, and multi-level ?include=a.b.c chains through the same code path. The two facades - Resource and JsonApi - are the same type under two names; use whichever matches your house style.

Defining a resource

use suprnova::Data;

#[derive(Debug, Clone, Data)]
#[json_resource("users")]
pub struct UserResource {
    pub id: i64,
    pub email: String,

    // `input_only` keeps `password` available on the form-request side
    // but suppresses it from the API output.
    #[data(input_only)]
    pub password: String,

    // Marks a field as a *relationship*: it never lands in `attributes`,
    // it produces a JSON:API relationship object instead, and it is
    // eligible for `?include=`. The field type must implement
    // `IntoJsonResource` (directly, or via `Vec<T>` / `Option<T>`).
    #[data(allow_include)]
    pub posts: Vec<PostResource>,
}

The id_field keyword renames the field that supplies the JSON:API id:

#[derive(Data)]
#[json_resource("orders", id_field = "uuid")]
pub struct OrderResource {
    pub uuid: String,
    pub total_cents: i64,
}

Rendering responses

Construct a pending response from a handler and call .render().await:

use suprnova::{LengthAwarePaginator, Resource};

#[handler]
async fn show_user(id: i64) -> Result<HttpResponse, FrameworkError> {
    let user: UserResource = User::find_or_fail(id).await?.into();
    Resource::single(user).render().await
}

#[handler]
async fn list_users() -> Result<HttpResponse, FrameworkError> {
    let users: Vec<UserResource> = User::all().await?.into_iter().map(Into::into).collect();
    Resource::collection(users).render().await
}

#[handler]
async fn paginate_users() -> Result<HttpResponse, FrameworkError> {
    // `paginate(per_page)` reads `?page=` from the current request automatically.
    let page = User::query().paginate(10).await?;
    // Convert the model paginator into a resource paginator field-by-field -
    // `data` is `pub`, the rest of the counts/links carry over.
    let page = LengthAwarePaginator::new(
        page.data.into_iter().map(UserResource::from).collect(),
        page.total,
        page.per_page,
        page.current_page,
    )
    .with_base_url("/api/users");
    Resource::paginated(page).render().await
}

JsonApi::single / JsonApi::collection / JsonApi::paginated are identical alias entry points if you prefer the Laravel spelling.

Chainable mutators

JsonApiResponse is a pending object. Customise the envelope before calling .render().await. Every mutator is self → Self so they compose:

use suprnova::{Resource, JsonApiInfo};
use serde_json::json;

let info = JsonApiInfo::new()
    .with_version("1.1")
    .with_ext("https://jsonapi.org/ext/atomic")
    .with_meta("copyright", json!("2026 Acme Inc."));

Resource::single(user)
    .status(201)                                  // HTTP status override
    .with_meta("trace_id", json!("req-7"))        // top-level meta KV
    .with_link("self", "/api/users/1")            // top-level link
    .with_jsonapi(info)                           // top-level `jsonapi`
    .additional(json!({ "api_version": "2.0" }).as_object().unwrap().clone())
    .render()
    .await
Mutator Laravel analogue Effect
.status(code) ResourceResponse::calculateStatus Overrides HTTP status.
.created() wasRecentlyCreated → 201 Shorthand for .status(201).
.with_meta(k, v) / .meta(k, v) with($request) Top-level meta KV.
.with_meta_map(m) bulk with($request) Merge a map into top-level meta.
.with_link(rel, href) / .link(rel, href) with($request)['links'] Top-level links KV.
.with_link_value(rel, v) link-object form Top-level link as {href, meta}.
.with_additional(k, v) additional($data) Root-level key alongside data.
.additional(map) additional($data) Bulk additional keys.
.with_jsonapi(info) JsonApiResource::configure(...) Top-level jsonapi member.

Canonical members (data, included, links, meta, jsonapi, errors) are never overwritten by .additional(...).

Override the IntoJsonResource::resource_links and IntoJsonResource::resource_meta defaults to attach links / metadata to the resource object, not the document root:

use suprnova::resources::IntoJsonResource;
use serde_json::{Map, Value};

impl IntoJsonResource for MyHandRolledPost {
    // ...

    fn resource_links(&self) -> Map<String, Value> {
        let mut m = Map::new();
        m.insert("self".into(), Value::String(format!("/api/posts/{}", self.id)));
        m
    }

    fn resource_meta(&self) -> Map<String, Value> {
        let mut m = Map::new();
        m.insert("kind".into(), Value::String("blog".into()));
        m
    }
}

Both default to an empty Map for macro-derived resources, so the JSON:API renderer omits the keys when not used. Override resource_top_level_meta to lift per-resource metadata into the envelope's top-level meta member.

Conditional attributes - Maybe<T> / MissingValue<T>

Use Maybe to omit a field from the rendered attributes object based on a runtime condition. This is the Suprnova analogue of Laravel's MissingValue and the when() / whenLoaded() / unless() family.

use suprnova::{Maybe, MissingValue};

// Both names point at the same type.
let m1: Maybe<&str> = Maybe::present("email@example.com");
let m2: MissingValue<&str> = MissingValue::missing();
let m3 = Maybe::when(user.is_verified, &user.verified_at);
let m4 = Maybe::unless(user.is_admin, &user.public_handle);
let m5 = Maybe::when_with(expensive_check(), || compute_value()); // lazy

For macro-derived structs, declare a field as Maybe<T> and the renderer drops it automatically when Missing. For hand-rolled resource_attributes, use the insert_maybe(map, key, maybe) helper:

use suprnova::resources::{insert_maybe, Maybe};

fn resource_attributes(&self, _fs: Option<&[&str]>) -> serde_json::Value {
    let mut map = serde_json::Map::new();
    insert_maybe(&mut map, "email", Maybe::present(&self.email));
    insert_maybe(
        &mut map,
        "phone",
        if self.show_phone { Maybe::present(&self.phone) } else { Maybe::missing() },
    );
    serde_json::Value::Object(map)
}

The renderer also calls strip_missing_values(&mut value) over the entire attributes object, so Maybe::Missing values nested inside arbitrary serde-derived structures are dropped recursively - useful when a deeply-nested transformer wants to omit subfields.

Sparse fieldsets

The framework's IncludeMiddleware parses ?fields[type]=email,name-style query parameters and binds them to a task-local. The macro-emitted resource_attributes consults the fieldset and only emits requested attributes. No handler-side work is needed - install the middleware and the resource layer honours it automatically.

// Request: GET /api/users/7?fields[users]=email
// Response: { "data": { "type": "users", "id": "7", "attributes": { "email": "alice@example.com" } } }

Compound documents - ?include= chains

Declare relationship fields with #[data(allow_include)]. The framework builds an IncludeTree from ?include=author.posts.tags,comments, walks every node, and pushes fully-resolved resource objects into included. Deduplication runs at push time through IncludedSink, keyed by (type, id) per JSON:API spec §8 - so a 1,000-item collection where every item shares the same author resolves the author exactly once. Peak memory and CPU stay proportional to the distinct included resources, not the relationship fan-in.

#[derive(Data)]
#[json_resource("posts")]
pub struct PostResource {
    pub id: i64,
    pub title: String,

    #[data(allow_include)]
    pub author: Option<AuthorResource>,

    #[data(allow_include)]
    pub tags: Vec<TagResource>,
}

A request that names an include path not on this resource's allowlist gets a JSON:API 400 errors envelope.

Depth cap

An include path may carry at most five segments. ?include=a.b.c.d.e.f is truncated to a.b.c.d.e before anything walks it, matching Laravel's JsonApiResource::$maxRelationshipDepth. Change the ceiling once at boot:

// In bootstrap::register()
suprnova::max_relationship_depth(3);

The cap matters because a relationship graph can be cyclic: ?include=author.posts.author.posts... costs more work with every segment a client types, and nothing else bounds it but the length of the query string. Truncation only removes segments, never adds them, and every level still checks its own allowlist before descending - so a truncated path can never reach data the full path could not.

One consequence is worth knowing: a segment past the cap is dropped before the allowlist sees it. At a cap of 2, ?include=author.posts.secrets returns 200 with author and posts included rather than the 400 the full path would earn, because secrets no longer exists by the time anything validates it.

max_relationship_depth(0) turns includes off entirely. Laravel's 0 still emits the first hop, because its clamp only ever applies to the tail after the leading segment has been split off; Suprnova's 0 means no relationships at all.

Why Suprnova diverges

Three visible divergences from Laravel's JsonApiResource:

  1. Strict default-deny for ?include=. Laravel's resource layer silently ignores include paths that don't resolve. Suprnova rejects them with a 400 Bad Request carrying a JSON:API errors envelope. The spec's §5.2.2 default-deny posture is the contract clients can program against; silent ignore hides client bugs and breaks compound-document integrity.

  2. Explicit .status(code) / .created() instead of auto-201. Laravel auto-sets 201 from wasRecentlyCreated on the underlying Eloquent model. Suprnova decouples the resource DTO from any specific persistence lifecycle, so the status is set on the response object itself - .created() when you mean it, .status(204) when the response is empty, and so on. A single mutator stays honest under any flow.

  3. A depth cap of 0 turns includes off entirely. Laravel clamps only the tail of a path, after the leading segment has already been split off, so its 0 still emits the first hop. Suprnova truncates the whole path, so max_relationship_depth(0) means no relationships at all - see Depth cap above.

Pagination

Resource::paginated(p) works with any paginator implementing the Paginated<T> trait - both LengthAwarePaginator<T> and CursorPaginator<T> from suprnova::pagination ship this impl. The renderer attaches links.{self,first,prev,next,last} and a meta.pagination block automatically.

use suprnova::{LengthAwarePaginator, Resource};

let page = LengthAwarePaginator::new(items, total, per_page, current_page)
    .with_base_url("/api/users");
Resource::paginated(page).render().await

Error envelopes

Every FrameworkError knows how to render itself as a JSON:API {"errors": [...]} envelope via into_json_api_response(). The helper is exposed because FrameworkError carries a status code, a field-name source pointer (for ValidationError), and a request-id correlation token under meta.request_id. 5xx responses are sanitised: the raw message never reaches the client unless APP_DEBUG=true is set in the active environment, in which case it appears under meta.debug_message.

let response = FrameworkError::validation("email", "email is invalid")
    .into_json_api_response();
// {
//   "errors": [{
//     "status": "422",
//     "title": "Validation failed",
//     "detail": "email is invalid",
//     "source": { "pointer": "/data/attributes/email" },
//     "meta": { "request_id": "..." }
//   }]
// }

Surfaces summary

Suprnova surface Laravel 13 equivalent
Resource / JsonApi facades JsonResource::make, JsonApiResource
JsonApiResponse ResourceResponse, JsonApiResource::toResponse
JsonApiBuilder (internal builder for ResourceResponse)
IntoJsonResource trait JsonResource::toArray, toAttributes, toRelationships, toLinks, toMeta, with
RelationshipValue / ResourceIdentifier array shape inside toRelationships
IncludeTree parsed ?include= from JsonApiRequest
RequestFieldsetSet parsed ?fields[type]= from JsonApiRequest
Maybe<T> / MissingValue<T> MissingValue + whenLoaded / when / unless
JsonApiInfo JsonApiResource::$jsonApiInformation
JsonApiResponse::status(code) / .created() ResourceResponse::calculateStatus
JsonApiResponse::additional(map) / .with_additional(k, v) JsonResource::additional($data)
JsonApiResponse::with_meta(k, v) / .meta(k, v) JsonResource::with($request)['meta']
JsonApiResponse::with_link(rel, href) / .link(rel, href) JsonResource::with($request)['links']
JsonApiResponse::with_jsonapi(info) JsonApiResource::configure(...)
current_fieldset() / scope_fieldset(...) task-local fieldset, set by IncludeMiddleware
IncludeResolutionError → 400 envelope strict-mode ?include= parser

Top-level re-exports under suprnova::: Resource, JsonApi, JsonApiResponse, JsonApiBuilder, JsonApiInfo, IncludedSink, IntoJsonResource, RelationshipValue, ResourceIdentifier, IncludeTree, RequestFieldsetSet, Maybe, MissingValue, insert_maybe, strip_missing_values, AsRelationshipValue, PushIncluded, IncludeResolutionError, current_fieldset, scope_fieldset.

Next

  • Eloquent serialization - #[derive(Data)], hidden/visible fields, the toArray equivalent that feeds resource attributes
  • Eloquent relationships - what #[data(allow_include)] consumes; the typed relation kinds backing compound documents
  • Pagination - LengthAwarePaginator, CursorPaginator, and the Paginated<T> trait Resource::paginated consumes
  • Data - the #[derive(Data)] macro shared with Inertia, the ?include=/?fields[type]= middleware, and Maybe<T> patterns
  • Error model - how FrameworkError::into_json_api_response fits the conversion contract