Manual contentsEloquent ORMBrowse 126 chapters
Manual 12 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?;
    // Transform each item while keeping the page metadata.
    let page = page.through(UserResource::from).with_path("/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(...).

Set application-wide implementation information once in your bootstrap:

use suprnova::{JsonApiInfo, jsonapi_default};

jsonapi_default(Some(JsonApiInfo::new().with_version("1.1")))?;

You apply the default to single resources, collections, and paginated collections, including empty collections. Override it for one response with .with_jsonapi(info). Pass None to jsonapi_default to clear it.

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 supply top-level meta for a single resource. For a collection, attach document metadata with .with_meta(...) or .with_meta_map(...). You keep that metadata at the root without copying any first item's top-level metadata. Each item's resource_meta remains inside its own resource object.

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.

Use merge_when(condition, fields) to include several attributes together. You store the group in a MergeValue<T> field, with fields that serialize to an object. The group's field name disappears from the output. Its members join the surrounding attributes when the condition is true:

use suprnova::{Data, MergeValue, Validate, merge_when};
use serde_json::{Value, json};

#[derive(Debug, Clone, Data, Validate)]
#[json_resource("users")]
pub struct UserResource {
    pub id: i64,
    pub name: String,
    pub details: MergeValue<Value>,
}

let resource = UserResource {
    id: user.id,
    name: user.name.clone(),
    details: merge_when(can_view_email, json!({ "email": user.email })),
};

You omit the whole group when the condition is false. A present group must serialize to an object; scalar fields produce a serialization error. Merged members follow sparse fieldsets under their own names, and a Maybe::Missing member is omitted while an explicit null remains.

Use with_exists(relation) when you need a flag without related rows:

use suprnova::when_exists_loaded;

let users = User::query().with_exists("posts").get().await?;
let user = &users[0];
let posts_exists = when_exists_loaded(user, "posts", |exists| exists);

You read the <relation>_exists flag with user.__eager.get_exists("posts"). You get Some(false) when the loaded relation has no rows, and None when you did not request the flag. Place posts_exists in a Maybe<bool> resource field to emit true or false when loaded and omit it otherwise. The closure runs only for a loaded flag. Existence flags coexist with counts and loaded rows; requesting a flag alone leaves the relation unloaded. An unknown relation returns an error, even when your query has no matches.

Sparse fieldsets

The framework's IncludeMiddleware parses ?fields[type]=email,name-style query parameters and binds them to a task-local. You get only the requested attributes after conditional groups merge. Install the middleware to bind the fieldset without extra handler work.

// 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. The path is checked against the resource types, not against the values in the response, so an empty collection refuses an unknown path too. A request that names a supported path always gets an included array, empty when nothing relates. A resource that is already primary data never repeats in included. A field marked #[data(input_only, allow_include)] is never sent: it has no relationship linkage, and including it is a 400.

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, escaped per RFC 6901, so an attribute named a/b is /data/attributes/a~1b), 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
merge_when / MergeValue<T> mergeWhen
when_exists_loaded whenExistsLoaded
jsonapi_default application-wide JsonApiResource::configure
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, merge_when, MergeValue, when_exists_loaded, jsonapi_default, 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