Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 22 additions & 58 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Adonis JSON:API

Serve a spec-compliant API from your existing Lucid models with a few lines per endpoint. Includes, sparse fieldsets, sorting, filtering, pagination, error documents, content negotiation and full write support are all handled for you.
Serve a spec-compliant JSON:API from your Lucid models. Every model serializes with no configuration, and each endpoint is a few lines. The package builds compound documents, includes, sparse fieldsets, sorting, filtering, pagination, error documents, content negotiation, and full write support.

```ts
// A complete JSON:API endpoint:
Expand All @@ -12,46 +12,21 @@ async index({ jsonApi }: HttpContext) {
}
```

New to JSON:API itself? Start with [What is JSON:API?](./docs/what-is-jsonapi.md)
New to the format? Start with [JSON:API concepts](./docs/concepts.md).

## Installation

```sh
node ace add @evoactivity/jsonapi-adonis
```

This installs the package and configures it: it creates `config/jsonapi.ts`, then registers the provider, the `jsonApi` named middleware and the generator commands.
This installs the package and configures it. It writes `config/jsonapi.ts`, registers the provider and the `jsonApi` named middleware, and registers the generator commands.

**Requirements:** AdonisJS v7 (`@adonisjs/core` ^7), Lucid v22 (`@adonisjs/lucid` ^22).
**Requirements.** AdonisJS v7 (`@adonisjs/core` ^7) and Lucid v22 (`@adonisjs/lucid` ^22).

## Quick start
## A complete controller

**1. Generate a resource and controllers** for one of your models:

```sh
node ace make:jsonapi:resource article --relationships --routes
```

This creates `app/resources/article_resource.ts` and the controllers, and registers the routes. You can also write them by hand, see the [reference](./docs/reference.md). Register the resource in `config/jsonapi.ts`:

```ts
export default defineConfig({
resources: [() => import('#resources/article_resource')],
})
```

> [!NOTE]
> Resource classes are optional. Every model serializes automatically with its type, attributes and relationships derived from Lucid metadata, so a controller alone works fine: `node ace make:jsonapi:controller article` generates just the controllers, and there's nothing to register in the config. Write a resource class when you want to customize the output, see [Customizing a resource](./docs/reading-data.md#customizing-a-resource).

**2. That's it. Make a request:**

```
GET /api/v1/articles/1?include=author,tags
```

You get a complete JSON:API document: the article as primary `data`, the author and tags in `included` (deduplicated), relationship linkage, `self` and `related` links, and the `application/vnd.api+json` content type. The `?include=` paths were validated and preloaded in one pass. Unknown paths get a 400, as the spec requires, and there are no N+1 queries.

The generated controller is plain AdonisJS. `jsonApi.query(Article)` is literally `Article.query()` with the request's `include`, `sort` and `filter` parameters applied, and you can chain `.where()`, scopes and `.paginate()` as usual:
`jsonApi.query(Model)` is `Model.query()` with the request's `include`, `sort`, and `filter` already applied, so you chain `.where()`, scopes, and `.paginate()` as usual. `jsonApi.render(...)` builds the document and sets the media type:

```ts
export default class ArticlesController {
Expand All @@ -74,44 +49,33 @@ export default class ArticlesController {
}
```

**3. Render errors as JSON:API documents.** One branch in your exception handler:

```ts
// app/exceptions/handler.ts
import { renderJsonApiError } from '@evoactivity/jsonapi-adonis'

async handle(error: unknown, ctx: HttpContext) {
if (ctx.jsonApi.handlesErrors()) {
return renderJsonApiError(error, ctx, this.debug)
}
return super.handle(error, ctx)
}
```

Models without a resource class serialize automatically, with the type, attributes and relationships derived from Lucid metadata. You only write resource classes to customize.
A request like `GET /api/v1/articles/1?include=author,tags` returns the article as `data`, the author and tags in `included` with duplicates removed, the linkage, `self` and `related` links, and the `application/vnd.api+json` content type. Unknown include paths get a `400`, and there are no N+1 queries. The [Getting started](./docs/getting-started.md) guide walks through the full setup.

## Documentation

| Guide | Covers |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------- |
| [What is JSON:API?](./docs/what-is-jsonapi.md) | The ideas behind the spec |
| [Reading data](./docs/reading-data.md) | Resources and types, customizing, `include`, sparse fieldsets, sorting, pagination, filtering |
| [Writing data](./docs/writing-data.md) | Create, update and delete from JSON:API documents, relationship endpoints |
| [Polymorphism](./docs/polymorphism.md) | Mixed-type relationships: the database shapes, trade-offs, and single-table inheritance |
| [Links](./docs/links.md) | Route-driven URL generation, API versioning, casing |
| [Errors & negotiation](./docs/errors.md) | Error documents, `handlesErrors()`, media type rules |
| [Low-level building blocks](./docs/low-level.md) | Serializing outside a request: commands, jobs, tests |
| [Reference](./docs/reference.md) | The `jsonApi` helper API, configuration, generators, roadmap |
| Guide | Covers |
| -------------------------------------------- | ----------------------------------------------------------- |
| [Concepts](./docs/concepts.md) | The format, and where this package fits |
| [Getting started](./docs/getting-started.md) | Install, generate, first request, error rendering |
| [Resources](./docs/resources.md) | How a model becomes a resource, and how to customize it |
| [Queries](./docs/queries.md) | `include`, sparse fieldsets, sorting, pagination, filtering |
| [Scopes](./docs/scopes.md) | Row visibility with `withScopes` and `withPreloadScopes` |
| [Writes](./docs/writes.md) | Create, update, delete, and the relationship endpoints |
| [Polymorphism](./docs/polymorphism.md) | Mixed-type relationships with single-table inheritance |
| [Links](./docs/links.md) | Route-driven URLs, API versioning, casing |
| [Errors and negotiation](./docs/errors.md) | Error documents, `handlesErrors()`, media type rules |
| [Building blocks](./docs/low-level.md) | Serializing outside a request: commands, jobs, tests |
| [Reference](./docs/reference.md) | The `jsonApi` helper API, config, generators, roadmap |

## The example app

[`examples/blog`](./examples/blog) is a complete AdonisJS application (articles, comments, tags, users) exercising every feature. The same resources are mounted under `/api/v1` and `/api/v2` to demonstrate versioned links.
[`examples/blog`](./examples/blog) is a complete AdonisJS application with articles, comments, tags, users, and attachments. It uses every feature, and mounts the same resources under `/api/v1` and `/api/v2` to show versioned links.

```sh
pnpm install
cd examples/blog
node ace migration:run
node ace db:seed # demo data: authors, articles, tags, comments
node ace db:seed
node ace serve --watch

curl 'localhost:3333/api/v1/articles?include=author,tags'
Expand Down
88 changes: 88 additions & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# JSON:API concepts

[JSON:API](https://jsonapi.org) is a specification for JSON APIs. It fixes the wire format in advance, so a server and a client written by different people agree on the shape of every response without a private contract. This package implements the server half for AdonisJS. This page explains the format it produces. The other guides explain how it produces it.

## The document

Every response is one document. This package models the document as a single TypeScript type, and the whole shape is small:

```ts
type Document = {
data?: ResourceObject | ResourceObject[] | null
errors?: ErrorObject[]
included?: ResourceObject[]
links?: Links
meta?: Meta
jsonapi?: { version: string }
}
```

A success response carries `data`. An error response carries `errors` instead. The two never appear together. `included` holds the related records the client asked for. `links` and `meta` carry URLs and side information.

## A resource

`data` holds resource objects. A resource object is one record:

```ts
type ResourceObject = {
type: string
id: string
attributes?: Record<string, unknown>
relationships?: Record<string, RelationshipObject>
links?: Links
meta?: Meta
}
```

Two fields identify it: a `type` and a string `id`. The spec requires the id to be a string, even for a numeric primary key. The record's own fields go under `attributes`. Its connections to other records go under `relationships`.

## A pointer, not a nested copy

A relationship does not hold the related record. It holds a pointer to it:

```ts
type ResourceIdentifier = { type: string; id: string }

type RelationshipObject = {
data?: ResourceIdentifier | ResourceIdentifier[] | null
links?: Links
}
```

So the author of an article is `{ "type": "users", "id": "7" }`, not a copy of the user. The user's fields arrive one time, in the top-level `included` array. A pointer instead of a nested copy is the core choice in the format, and it does three things.

**One copy.** If Alice wrote an article and ten of its comments, she is one entry in `included`, and eleven pointers name `users:7`. A nested format would send her eleven times.

**One identity.** `type` plus `id` is an address. A client cache stores each record one time under that address, so a later edit to Alice updates every view that points at her. A nested copy has no address, so the client cannot tell which copies to change.

**Two kinds of empty.** `data: []` means the relationship is empty. A relationship with links and no `data` means not loaded. A nested `[]` cannot tell those apart.

## Reading a document

Read raw, a pointer puts a field two hops away:

```js
const article = response.data.data
const authorId = article.relationships.author.data.id
const author = response.data.included.find((r) => r.type === 'users' && r.id === authorId)
author.attributes.fullName
```

You do not write that. The shape is identical on every JSON:API server, so one library rejoins the pointers to their records:

```js
import { Jsona } from 'jsona'

const article = new Jsona().deserialize(response.data)
article.author.fullName // relationship resolved from included
```

Existing clients already do this: `jsona`, `Kitsu`, `jsonapi-react`, the typed definitions in `jsonapi-typescript`, and native clients for Swift and Kotlin. They also do pagination, includes, and sparse fieldsets, because those behave identically on every compliant server.

## Where this package fits

`@evoactivity/jsonapi-adonis` builds these documents from your Lucid models. It reads the query parameters, writes rows from request documents, serves the relationship endpoints, and renders errors as error documents. Your models are the source of the data. The rest of these guides cover each part.

---

Next: [Getting started](./getting-started.md) · [Resources](./resources.md) · [Queries](./queries.md) · [Reference](./reference.md)
79 changes: 35 additions & 44 deletions docs/errors.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Errors and content negotiation

## What an error document looks like
## The error document

A JSON:API error response has no `data`. It carries an `errors` array instead, with one error object per problem, and is served as `application/vnd.api+json` like everything else. This is a real response from the example app, a validation failure on `POST /api/v1/articles`:
An error response has no `data`. It carries an `errors` array, one object per problem, served as `application/vnd.api+json` like every other response. This is a real validation failure on `POST /api/v1/articles`:

```json
HTTP/1.1 422 Unprocessable Content
Expand All @@ -29,38 +29,35 @@ Content-Type: application/vnd.api+json
}
```

Each error object can carry:
An error object can carry these members:

| Member | Meaning |
| ------------------ | --------------------------------------------------------------------- |
| `status` | The HTTP status, as a string (one document can mix statuses) |
| `code` | An application-specific identifier, here the failing Vine rule |
| `title` | A short, general description of the problem |
| `detail` | A human-readable explanation of this occurrence |
| `source.pointer` | A JSON Pointer into the request document that caused the problem |
| `source.parameter` | The query parameter at fault, for input errors like a bad `?include=` |
| `source.header` | The header at fault, for content negotiation failures |
| `meta` | Anything else you want to attach |
| Member | Meaning |
| ------------------ | ------------------------------------------------------------------- |
| `status` | The HTTP status, as a string. One document can mix statuses |
| `code` | An application-specific code, here the failing Vine rule |
| `title` | A short, general description of the problem |
| `detail` | A human-readable explanation of this occurrence |
| `source.pointer` | A JSON Pointer into the request document at fault |
| `source.parameter` | The query parameter at fault, for a bad `?include=` or `?filter[]=` |
| `source.header` | The header at fault, for a content negotiation failure |
| `meta` | Anything else you attach |

Query-parameter problems point at the parameter instead of the body. Asking for `?include=nonsense` returns:
`source` tells the client where the problem is. A body problem points at a pointer. A query problem points at the parameter. A bad `?include=nonsense` returns `source: { parameter: "include" }` and a `400`.

```json
{
"jsonapi": { "version": "1.1" },
"errors": [
{
"status": "400",
"title": "Invalid Query Parameter",
"detail": "\"nonsense\" is not a supported include path for Article",
"source": { "parameter": "include" }
}
]
}
```
## One function maps every error

`toErrorDocument(error, debug)` is a pure function that turns any thrown value into `{ status, body }`. It has four branches:

- A `JsonApiException` already carries its own error objects (an invalid parameter, a deserialization conflict). The package throws these, and you can throw your own.
- A VineJS validation error becomes a `422` with one error object per failed field, each with a `/data/attributes/...` pointer.
- Any other HTTP exception (a `404` from `findOrFail`, an auth failure) maps its status and a matching title.
- Anything else is an opaque `500`. The `detail` is filled only in debug mode, so an internal message never leaks in production.

## Rendering errors
Because it is pure, the same function serves a job or a test with no HTTP request. See [Building blocks](./low-level.md#error-documents-anywhere).

Every error can render as a spec-compliant errors document. Delegate from your exception handler:
## Rendering errors in your app

`toErrorDocument` runs from your exception handler through `renderJsonApiError`. Guard it so only JSON:API requests get JSON:API errors:

```ts
// app/exceptions/handler.ts
Expand All @@ -74,35 +71,29 @@ async handle(error: unknown, ctx: HttpContext) {
}
```

`handlesErrors()` detects JSON:API requests automatically: either the matched route was registered via `router.jsonApiResource()`, or the client is speaking the JSON:API media type in its `Accept` or `Content-Type` header. When you'd rather decide yourself (say, everything under a URL prefix, including unmatched 404s), set the predicate in `config/jsonapi.ts`:
`handlesErrors()` returns true when either condition holds: the matched route was registered by `router.jsonApiResource()`, or the client named the JSON:API media type in its `Accept` or `Content-Type` header. To decide another way, for example every URL under a prefix including unmatched 404s, set a predicate in the config:

```ts
export default defineConfig({
errorDetection: (ctx) => ctx.request.url().startsWith('/api/'),
})
```

What renders how:

- VineJS validation failures become `422` with one error object per failure, each pointing into the request document (`source: { pointer: "/data/attributes/title" }`).
- HTTP exceptions (404s from `findOrFail`, auth failures, …) map their status and title.
- Anything else is an opaque `500`, with details included only in debug mode.
- Exceptions thrown by this package (invalid parameters, deserialization conflicts, …) are `JsonApiException` instances carrying ready-made error objects. You can throw your own, too.

## Content negotiation

The `jsonApi` middleware implements the spec's media type rules:
The `jsonApi` named middleware runs the spec's media type rules. Apply it to your resource route group.

- A JSON:API `Content-Type` carrying media type parameters gets a `415`, and an `Accept` header whose JSON:API offers are all parameterized gets a `406`.
- `profile` parameters always pass, since the spec lets servers ignore unrecognized profiles.
- `ext` parameters are honored as the contract they are: an extension this package doesn't support is rejected with `415`/`406` rather than silently processed as a plain document. No extensions are supported yet. Atomic Operations will be the first.
- A request `Content-Type` of the JSON:API media type, with any media type parameter other than `profile` or a supported `ext`, is a `415`.
- An `Accept` header that names the JSON:API media type, where no listed instance of it is acceptable, is a `406`.
- A `profile` parameter always passes, because the spec lets a server ignore a profile it does not know.
- An `ext` parameter is a contract. An extension the package does not support is a `415` or `406`, not a document processed as if the extension were absent. No extensions are supported yet. Atomic Operations will be the first.

All responses are served as `application/vnd.api+json`.
Every response is served as `application/vnd.api+json`.

## Strict query parameters

One more strict-input rule lives in the query-string parser. The spec reserves simple lowercase parameter names for itself, which makes an unrecognized all-lowercase parameter (`?foo=bar`) a `400`. Application-specific parameters must contain a non-lowercase character (`?cacheBust=1`, `?api_key=…`) and are ignored by the package.
The spec reserves simple lowercase parameter names for itself. So an unrecognized all-lowercase parameter (`?foo=bar`) is a `400`. Your own parameters must contain a non-lowercase character (`?cacheBust=1`, `?api_key=…`), and the package ignores them.

---

Next: [Reference](./reference.md)
Next: [Building blocks](./low-level.md) · [Reference](./reference.md)
Loading