jsonapi.rest
GitHub

The API, by example

One request shape.
Every framework speaks it.

JSON:API is a standard for how a REST API shapes its requests and responses — so clients, docs, caches and codegen can be shared instead of rebuilt per endpoint. This is the guided tour: the moves every API makes — read one, read many, filter, shape, write, and batch — each shown as the raw HTTP and the typed TypeScript client, side by side. No prior JSON:API knowledge assumed.

The words you'll see

A vocabulary, defined once.

JSON:API gives a few plain ideas precise names. Here they are up front — everything below is just these, in motion.

Resource

One thing your API exposes — an album, an artist. Every resource has a type and an id, and carries its data as attributes.

Relationships

Typed links between resources — an album belongs to an artist and has many tracks. Each is addressable, and can be fetched, counted or paginated on its own.

Compound documents

Ask for related resources with ?include= and they ride along in the same response under included — one round trip, no N+1.

Sparse fieldsets

?fields[albums]=title,artist trims each resource to just the members you name — smaller payloads, on demand.

The media type

Every request and response is application/vnd.api+json. That one header is how server and client agree they're speaking JSON:API.

Error documents

Failures come back as a structured errors array — each with a status, a human detail and a source pointer at the exact field. A 422 maps straight onto a form.

One class, the whole surface

Declare it once.

Everything on the rest of this page — every request, every response — is served by this one resource class. You declare a type's fields, which are sortable, and its relationships; the library parses each request, validates it, loads the data and shapes the spec-compliant response. No controllers, no per-endpoint serializers.

  • Fields — typed attributes with validation (required, maxLength, …) baked in
  • RelationshipsBelongsTo, HasMany, countable and paginable
  • Framework-agnostic — the same class runs standalone, or auto-wires in Symfony & Laravel with one attribute
AlbumResource.php
final class AlbumResource extends AbstractResource
{
    public static string $type = 'albums';

    public function fields(): array
    {
        return [
            Id::make(),
            Str::make('title')->required()->sortable(),
            Str::make('status')->sortable(),
            DateTime::make('releasedAt')->sortable(),
            BelongsTo::make('artist', 'artists'),
            HasMany::make('tracks', 'tracks')->countable(),
        ];
    }
}

// the same class runs standalone — add #[AsJsonApiResource]
// to auto-wire it in Symfony or Laravel. No controller either way.

Fetch one

A single resource, by id.

GET /albums/1 returns one resource: its attributes, and its relationships as bare identifiers (a { type, id }, or a link to fetch them). In the client that becomes one flat, typed object — attributes are plain properties, the envelope rides on the side.

Request

GET /albums/1
Accept: application/vnd.api+json

Response · 200 OK

{
  "data": {
    "type": "albums",
    "id": "1",
    "attributes": {
      "title": "OK Computer",
      "status": "released",
      "releasedAt": "1997-05-21"
    },
    "relationships": {
      "artist": { "data": { "type": "artists", "id": "9" } },
      "tracks": { "links": { "related": "/albums/1/tracks" }, "meta": { "count": 12 } }
    },
    "links": { "self": "/albums/1" }
  }
}

Fetch a collection

Many at once, paginated.

A list read returns an array of resources plus links to page through them. The client hands you a genuine array — a Collection<Album> — with the JSON:API envelope and page navigation riding on the side.

Request

GET /albums
Accept: application/vnd.api+json

Response · 200 OK

{
  "data": [
    {
      "type": "albums",
      "id": "1",
      "attributes": {
        "title": "OK Computer",
        "status": "released",
        "releasedAt": "1997-05-21"
      },
      "relationships": {
        "artist": { "data": { "type": "artists", "id": "9" } },
        "tracks": { "links": { "related": "/albums/1/tracks" }, "meta": { "count": 12 } }
      }
    },
    {
      "type": "albums",
      "id": "7",
      "attributes": {
        "title": "Kid A",
        "status": "released",
        "releasedAt": "2000-10-02"
      },
      "relationships": {
        "artist": { "data": { "type": "artists", "id": "9" } },
        "tracks": { "links": { "related": "/albums/7/tracks" }, "meta": { "count": 10 } }
      }
    }
  ],
  "links": { "self": "/albums", "next": "/albums?page[number]=2" },
  "meta": { "page": { "total": 42 } }
}

Filter & sort

Narrow and order, type-checked.

Filter and sort compose on a list read. In the client, every key is narrowed to what the server actually advertises for that type — a filter key or sort field the API doesn't support is a compile error, the static mirror of the server's 400.

Request

GET /albums?filter[title]=OK&sort=-releasedAt
Accept: application/vnd.api+json

Response · 200 OK

{
  "data": [
    {
      "type": "albums",
      "id": "1",
      "attributes": {
        "title": "OK Computer",
        "status": "released",
        "releasedAt": "1997-05-21"
      },
      "relationships": {
        "artist": { "data": { "type": "artists", "id": "9" } }
      }
    }
  ],
  "links": { "self": "/albums?filter[title]=OK&sort=-releasedAt" }
}

Compound documents

Pull relations in, hydrated.

A relationship is an identifier until you ask for it. Add ?include=artist,tracks and those related resources ride along in the same response under included — one round trip, no N+1. The wire ships each related resource once and references it by type/id; the client stitches those references back into the parent as real nested objects. And because include is captured as a literal tuple, the result type widens with it — the relations you named become hydrated resources, the rest stay identifiers.

Request

GET /albums/1?include=artist,tracks
Accept: application/vnd.api+json

Response · 200 OK

{
  "data": {
    "type": "albums",
    "id": "1",
    "attributes": { "title": "OK Computer" },
    "relationships": {
      "artist": { "data": { "type": "artists", "id": "9" } },
      "tracks": { "data": [{ "type": "tracks", "id": "31" }, { "type": "tracks", "id": "32" }] }
    }
  },
  "included": [
    { "type": "artists", "id": "9",  "attributes": { "name": "Radiohead" } },
    { "type": "tracks",  "id": "31", "attributes": { "title": "Airbag" } },
    { "type": "tracks",  "id": "32", "attributes": { "title": "Paranoid Android" } }
  ]
}

Sparse fieldsets

Ask for less, get less.

fields[albums]=title,status tells the server to emit only those members of each album — every other attribute and relationship is dropped from the payload. In the client the effect is the same on the type: an unrequested member is statically absent, so reading it is a compile error, not a runtime undefined. Smaller payloads and a tighter type, from one query parameter.

Request

GET /albums?fields[albums]=title,status
Accept: application/vnd.api+json

Response · 200 OK

{
  "data": [
    {
      "type": "albums",
      "id": "1",
      "attributes": { "title": "OK Computer", "status": "released" }
      // releasedAt, and the artist / tracks relationships — all omitted
    },
    {
      "type": "albums",
      "id": "7",
      "attributes": { "title": "Kid A", "status": "released" }
    }
  ]
}

Create, update & delete

Mutations, flat in and out.

Writes are the same envelope in reverse. The client takes a flat attributes object — no data, no attributes wrapper, no repeated type — and hands back the materialised resource. A rejected write throws a typed error mapped back to the field you sent.

Request · create

POST /albums
Content-Type: application/vnd.api+json

{ "data": { "type": "albums", "attributes": { "title": "Kid A", "status": "released" } } }

Response · 201 Created

Location: /albums/10

{ "data": { "type": "albums", "id": "10", "attributes": { "title": "Kid A", "status": "released" } } }

Update & delete

PATCH /albums/10   # partial — send only what changed  → 200
{ "data": { "type": "albums", "id": "10", "attributes": { "title": "OK Computer (Remaster)" } } }

DELETE /albums/10                                       # → 204 No Content

Atomic operations

Many writes, one transaction.

The JSON:API atomic extension batches several writes into one all-or-nothing request. A later operation can reference a resource an earlier one just created — by a local id (lid) on the wire, or just the returned handle in the client. It all commits together, or none of it does.

Request

POST /operations
Content-Type: application/vnd.api+json; ext="https://jsonapi.org/ext/atomic"

{
  "atomic:operations": [
    {
      "op": "add",
      "data": {
        "type": "artists",
        "lid": "atomic-0",
        "attributes": { "name": "Boards of Canada" }
      }
    },
    {
      "op": "add",
      "data": {
        "type": "albums",
        "attributes": { "title": "Geogaddi" },
        "relationships": {
          "artist": { "data": { "type": "artists", "lid": "atomic-0" } }
        }
      }
    }
  ]
}

Response · 200 OK

{
  "atomic:results": [
    {
      "data": {
        "type": "artists",
        "id": "99",
        "attributes": { "name": "Boards of Canada" }
      }
    },
    {
      "data": {
        "type": "albums",
        "id": "42",
        "attributes": { "title": "Geogaddi" }
      }
    }
  ]
}

That's the tour

Put your API to rest.

Same shapes, whichever way you build — the Symfony bundle, the Laravel package, or the framework-agnostic core — with a typed TypeScript client generated from the very same contract. Pick a stack and go.