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 Data;
The id_field keyword renames the field that supplies the JSON:API id:
Rendering responses
Construct a pending response from a handler and call .render().await:
use ;
async
async
async
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 ;
use json;
let info = new
.with_version
.with_ext
.with_meta;
single
.status // HTTP status override
.with_meta // top-level meta KV
.with_link // top-level link
.with_jsonapi // top-level `jsonapi`
.additional
.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(...).
Per-resource links and meta
Override the IntoJsonResource::resource_links and
IntoJsonResource::resource_meta defaults to attach links / metadata
to the resource object, not the document root:
use IntoJsonResource;
use ;
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 ;
// Both names point at the same type.
let m1: = present;
let m2: = missing;
let m3 = when;
let m4 = unless;
let m5 = when_with; // 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 ;
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.
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()
max_relationship_depth;
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:
-
Strict default-deny for
?include=. Laravel's resource layer silently ignores include paths that don't resolve. Suprnova rejects them with a400 Bad Requestcarrying 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. -
Explicit
.status(code)/.created()instead of auto-201. Laravel auto-sets201fromwasRecentlyCreatedon 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. -
A depth cap of
0turns includes off entirely. Laravel clamps only the tail of a path, after the leading segment has already been split off, so its0still emits the first hop. Suprnova truncates the whole path, somax_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 ;
let page = new
.with_base_url;
paginated.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 = validation
.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, thetoArrayequivalent that feeds resource attributes - Eloquent relationships - what
#[data(allow_include)]consumes; the typed relation kinds backing compound documents - Pagination -
LengthAwarePaginator,CursorPaginator, and thePaginated<T>traitResource::paginatedconsumes - Data - the
#[derive(Data)]macro shared with Inertia, the?include=/?fields[type]=middleware, andMaybe<T>patterns - Error model - how
FrameworkError::into_json_api_responsefits the conversion contract
