# 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

```rust
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`:

```rust
#[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`:

```rust
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:

```rust
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(...)`.

## 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:

```rust
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.

```rust
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:

```rust
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.

```rust
// 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.

```rust
#[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.

### Why Suprnova diverges

Two 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.

## 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.

```rust
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`.

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