diff --git a/README.md b/README.md index d2c1f7a..f78277a 100644 --- a/README.md +++ b/README.md @@ -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: @@ -12,7 +12,7 @@ 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 @@ -20,38 +20,13 @@ New to JSON:API itself? Start with [What is JSON:API?](./docs/what-is-jsonapi.md 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 { @@ -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' diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..061dd0b --- /dev/null +++ b/docs/concepts.md @@ -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 + relationships?: Record + 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) diff --git a/docs/errors.md b/docs/errors.md index 403d01d..f1eea70 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -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 @@ -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 @@ -74,7 +71,7 @@ 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({ @@ -82,27 +79,21 @@ export default defineConfig({ }) ``` -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) diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..9b85b58 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,111 @@ +# Getting started + +This guide goes from install to a working endpoint. It assumes AdonisJS v7, Lucid v22, and at least one model. + +## 1. Install + +```sh +node ace add @evoactivity/jsonapi-adonis +``` + +The `add` command runs the package's configure step. That step does four things: + +- writes `config/jsonapi.ts` +- registers the provider +- registers the generator commands +- registers a named middleware called `jsonApi` + +After it finishes, `ctx.jsonApi` exists on every request. + +## 2. Generate a resource and controllers + +```sh +node ace make:jsonapi:resource article --relationships --routes +``` + +This writes `app/resources/article_resource.ts`, an `articles` controller with index/show/store/update/destroy, and (from `--relationships`) a relationships controller. `--routes` appends a `router.jsonApiResource(...)` group to `start/routes.ts`. The command then prints the config line to add: + +```ts +// config/jsonapi.ts +export default defineConfig({ + resources: [() => import('#resources/article_resource')], +}) +``` + +> [!NOTE] +> Resource classes are optional. Every model serializes from Lucid metadata alone, so a controller is enough on its own. `node ace make:jsonapi:controller article` generates only the controllers, and you register nothing in the config. You write a resource class only to change the output. See [Resources](./resources.md). + +## 3. Apply the middleware + +The `jsonApi` middleware runs content negotiation. Put your resource routes in a group and apply it. Name the group, because link generation reads the name: + +```ts +// start/routes.ts +import { middleware } from '#start/kernel' +import router from '@adonisjs/core/services/router' + +router + .group(() => { + router.jsonApiResource('articles', { + resource: () => import('#controllers/articles_controller'), + relationships: () => import('#controllers/article_relationships_controller'), + }) + }) + .prefix('/api/v1') + .as('api.v1') + .use(middleware.jsonApi()) +``` + +The `.as('api.v1')` name drives the URLs in every document. See [Links](./links.md). + +## 4. Make a request + +``` +GET /api/v1/articles/1?include=author,tags +``` + +The generated controller is plain AdonisJS: + +```ts +export default class ArticlesController { + async index({ jsonApi }: HttpContext) { + const articles = await jsonApi.query(Article).paginate(...jsonApi.page) + return jsonApi.render(articles) + } + + async show({ jsonApi, params }: HttpContext) { + const article = await jsonApi.query(Article).where('id', params.id).firstOrFail() + return jsonApi.render(article) + } +} +``` + +`jsonApi.query(Article)` returns `Article.query()` with the request's `include`, `sort`, and `filter` already applied, so you can chain `.where()` and `.paginate()` as usual. `jsonApi.render(...)` builds the document and sets the `application/vnd.api+json` content type. The response holds the article in `data`, the author and tags in `included` with duplicates removed, the linkage, and the links. Unknown include paths get a `400`, and there are no N+1 queries. + +## 5. Render errors as JSON:API documents + +Add one branch to 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) +} +``` + +`handlesErrors()` returns true for a JSON:API request. A validation error then becomes a `422` document with one pointer per failed field, and any other error maps to its status. See [Errors and negotiation](./errors.md). + +## Next steps + +- [Resources](./resources.md): change how a model serializes. +- [Queries](./queries.md): `include`, sparse fieldsets, sorting, pagination, filtering. +- [Writes](./writes.md): create, update, delete, and the relationship endpoints. + +--- + +Next: [Resources](./resources.md) · [Queries](./queries.md) · [Writes](./writes.md) · [Reference](./reference.md) diff --git a/docs/links.md b/docs/links.md index 077f5b9..0f9c705 100644 --- a/docs/links.md +++ b/docs/links.md @@ -1,10 +1,6 @@ # Links -Why JSON:API documents carry links, what the relationship links buy you, and how this package generates them. - -## Why links at all - -Every resource in a response carries a `self` link, and every relationship carries `self` and `related` links. That means a client never has to construct URLs from conventions it hopes the server follows. It reads them out of the document: +A JSON:API document carries the URLs a client needs, so the client reads them instead of building them from a convention it hopes the server keeps: ```json { @@ -22,47 +18,50 @@ Every resource in a response carries a `self` link, and every relationship carri } ``` -The server stays in charge of its own URL space. You can restructure routes, add a version prefix, or mount the same API twice, and clients that follow links keep working. +The server owns its URL space. You can restructure routes, add a version prefix, or mount the same API twice, and a client that follows links keeps working. -Links also make lazy loading natural. When a to-many relationship wasn't loaded, this package emits the relationship with links only, no `data`. The client sees that the relationship exists, and has a URL to fetch it when it actually needs it, instead of the server guessing what to preload for everyone. +## Links come from named routes -## Why relationship links matter: concurrent edits +The package never builds a URL from a string template. It builds every URL from a named route registered by `router.jsonApiResource()`. That helper names its routes by convention: -The `related` link fetches the resources on the other side. The `self` link is more interesting: it points at the _relationship itself_, and PATCH/POST/DELETE on it edit the linkage without touching either resource. That distinction sounds academic until two users edit the same relationship at the same time. +``` +articles.show +articles.relationships.show +articles.related +``` -Say an article has tags `a, b, c, d, e`. Alice wants to remove `c` and `e`. Bob wants to remove `a` and `b`. Both are looking at the same starting list. +Two consequences follow from generating URLs this way. -If they each update the parent resource (or send a full-replacement PATCH of the relationship), they send snapshots computed from what they saw: +**A link that would 404 is not emitted.** Before it writes a link, the builder asks the router whether the named route exists. If it does not, the member has no link. So a model that is serialize-only, with no routes registered, gets no `self` link, instead of a link that leads nowhere. -1. Alice sends `data: [a, b, d]`. The server stores it. -2. Bob sends `data: [c, d, e]`, computed from the stale original. -3. Final state: `c, d, e`. Bob has resurrected the two tags Alice just deleted, and his own deletions wiped out hers. Last write wins, and both of them lose. +**Versioning is automatic.** The builder reads the name of the route that served the current request, takes its namespace (`api.v1`), and generates every link inside that same namespace. Mount the same resources under an `api.v1` group and an `api.v2` group, and a request to v2 produces v2 links, including the `Location` header on a `201`. Nothing in the controller changes. -If they instead send deltas to the relationship URL: +To turn links off, set `links: false` in `config/jsonapi.ts`. -1. Alice: `DELETE /articles/1/relationships/tags` with `data: [c, e]` -2. Bob: `DELETE /articles/1/relationships/tags` with `data: [a, b]` -3. Final state: `d`, in either order. Both intents survive because remove-these-members and add-these-members are operations, not snapshots, and they compose. +## Relationship links and concurrent edits -This is why the spec defines POST (add) and DELETE (remove) on to-many relationship URLs, and why it explicitly permits servers to refuse full replacement. It's also why this package returns `403` for hasMany full replacement: an endpoint that invites lost updates is worse than one that asks clients to say what they actually mean. +Every relationship carries two links. `related` fetches the resources on the other side. `self` points at the relationship itself, and `PATCH`, `POST`, or `DELETE` on it edit the linkage without touching either resource. That distinction matters the moment two people edit one relationship at once. -The same logic applies to your own clients. If a UI lets someone add or remove items from a list, wiring it to POST/DELETE on the relationship link is both simpler and safer than diffing state and PATCHing the parent. +An article has tags `a, b, c, d, e`. Alice wants to remove `c` and `e`. Bob wants to remove `a` and `b`. Both start from the same list. -## How links are generated +If each one sends a full-replacement `PATCH`, they send snapshots computed from what they saw: -Resource and relationship URLs come from named routes, not string templates. `router.jsonApiResource('articles', ...)` names its routes `articles.show`, `articles.relationships.show`, `articles.related` and so on, prefixed by the surrounding groups' `.as()` names. +1. Alice sends `[a, b, d]`. The server stores it. +2. Bob sends `[c, d, e]`, computed from the stale original. +3. Final state: `c, d, e`. Bob has restored the two tags Alice removed, and his own removals erased hers. Last write wins, and both lose. -When rendering, the package looks at the route that served the current request, recovers its namespace, and generates links inside that same namespace. This buys you two things: +If each one sends a delta to the relationship URL: -- API versioning just works. Mount the same resources under `/api/v1` and `/api/v2` groups and the v2 responses link to `/api/v2/...`, including the `Location` header on creation. -- No broken links. A link is only emitted when the named route actually exists. Models that are serialize-only, with no routes registered, get no `self` link instead of a link that 404s. +1. Alice: `DELETE …/relationships/tags` with `[c, e]`. +2. Bob: `DELETE …/relationships/tags` with `[a, b]`. +3. Final state: `d`. Both intents survive, because remove-these and add-these are operations, not snapshots, so they compose. -Don't want links at all? Set `links: false` in `config/jsonapi.ts`. +This is why the spec puts `POST` (add) and `DELETE` (remove) on to-many relationship URLs, and why it lets a server refuse full replacement. It is also why this package answers hasMany full replacement with `403`. See [Writes](./writes.md#what-each-relation-kind-accepts-on-write). The same reasoning applies to your own UI: wire an add/remove control to `POST`/`DELETE` on the relationship link, not to a diff-and-PATCH of the parent. ## Casing -URL path segments are kebab-cased: a `receivedComments` relation lives at `/users/1/relationships/received-comments`, and the endpoints accept the kebab form transparently. Member names inside documents (attributes, relationship keys) stay camelCase, matching the official JSON:API recommendation. Auto-derived resource types are kebab-cased too, turning an `access_tokens` table into the `access-tokens` type. +A URL path segment is kebab-cased. A `receivedComments` relation is at `/users/1/relationships/received-comments`, and the endpoints accept the kebab form. Member names inside documents (attributes, relationship keys) stay camelCase, which matches the JSON:API recommendation. An auto-derived type is kebab-cased too, so an `access_tokens` table becomes the `access-tokens` type. --- -Next: [Errors & negotiation](./errors.md) · [Reference](./reference.md) +Next: [Errors and negotiation](./errors.md) · [Building blocks](./low-level.md) · [Reference](./reference.md) diff --git a/docs/low-level.md b/docs/low-level.md index 22839a8..7e38e21 100644 --- a/docs/low-level.md +++ b/docs/low-level.md @@ -1,23 +1,23 @@ -# The building blocks: using @evoactivity/jsonapi-adonis outside a request +# Building blocks -The `jsonApi` context helper is a thin facade. Everything it does is built from exported pieces you can compose yourself, which is how you produce or consume JSON:API documents where there is no HTTP request: ace commands, queue jobs, scheduled tasks, tests, webhook processors, or static exports for a frontend. +`ctx.jsonApi` is a thin composition of exported pieces. `query()` is `parseQueryParams` plus `validateIncludeTree`, `applyIncludes`, `applySort`, and `applyFilters`. `render()` is a `DocumentBuilder` and a `LinkBuilder`. `deserialize()` is `deserializeResourceDocument` plus `verifyRelatedExist`. Every piece is exported, so you can produce or consume JSON:API documents where there is no request: an ace command, a queue job, a scheduled task, a test, a webhook body, or a static export. ## The pieces -| Export | Role | -| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `JsonApiRegistry` | Maps models to resource classes, derives types, auto-creates resources for unregistered models | -| `DocumentBuilder` | Turns rows or a paginator into a compound document (`data`, deduped `included`, sparse fieldsets) | -| `LinkBuilder` | Route-driven URL generation (or `new LinkBuilder(false)` for none) | -| `parseQueryParams` | `{ include, fields, sort, page, filter }` from a plain object, with spec validation | -| `validateIncludeTree` / `applyIncludes` / `applySort` / `applyFilters` | Apply parsed params to a Lucid query | -| `deserializeResourceDocument` / `verifyRelatedExist` | Request document to model attributes + to-many ids | -| `toErrorDocument` | Any thrown error to a `{ status, body }` errors document (pure) | -| `JsonApiResource`, `filter`, `JsonApiException`, document types | The same classes and types used everywhere else | +| Export | Role | +| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `JsonApiRegistry` | Maps models to resource classes; auto-derives the rest; resolves a row's concrete type | +| `DocumentBuilder` | Rows or a paginator to a compound document (`data`, deduped `included`, sparse fields) | +| `LinkBuilder` | Route-driven URLs, or `new LinkBuilder(false)` for none | +| `parseQueryParams` | A plain object to `{ include, fields, sort, page, filter }`, validated | +| `validateIncludeTree` / `applyIncludes` / `applySort` / `applyFilters` | Apply parsed params to a Lucid query | +| `deserializeResourceDocument` / `verifyRelatedExist` | A request document to attributes plus to-many ids, then existence checks | +| `toErrorDocument` | Any thrown value to a `{ status, body }` error document (pure) | +| `JsonApiResource`, `filter`, `JsonApiException`, document types | The same classes and types used everywhere else | -## Getting the configured registry +## Get the configured registry -The provider binds its registry, with every resource class from `config/jsonapi.ts` registered, into the container as a singleton keyed by the class itself: +The provider registers a `JsonApiRegistry` singleton, holding every resource class from `config/jsonapi.ts`. Resolve it from the container to reuse exactly what the HTTP layer uses: ```ts import { JsonApiRegistry } from '@evoactivity/jsonapi-adonis' @@ -25,11 +25,11 @@ import { JsonApiRegistry } from '@evoactivity/jsonapi-adonis' const registry = await app.container.make(JsonApiRegistry) ``` -You can also construct a fresh `new JsonApiRegistry()` and `.register([...])` resource classes manually. That's useful in unit tests, or when you want different resources than the HTTP layer exposes. +For a different set of resources, build one by hand: `new JsonApiRegistry().register([...])`. This is useful in a unit test, or when a job needs a narrower view than the API exposes. -## Serializing without a request +## Serialize without a request -Here is a complete, runnable example: an ace command that exports articles as a JSON:API document. This exact command ships in the example app as `examples/blog/commands/export_articles.ts`. +This command ships in the example app as `examples/blog/commands/export_articles.ts`. It builds a document by driving the same pieces `ctx.jsonApi` composes: ```ts import { BaseCommand, flags } from '@adonisjs/core/ace' @@ -47,7 +47,7 @@ import { export default class ExportArticles extends BaseCommand { static commandName = 'export:articles' static description = 'Export all articles as a JSON:API document to stdout' - static options: CommandOptions = { startApp: true } // boot DB + provider + static options: CommandOptions = { startApp: true } // boot the DB and provider @flags.string({ description: 'Include paths (same syntax as ?include=)', default: 'author' }) declare include: string @@ -56,20 +56,19 @@ export default class ExportArticles extends BaseCommand { const { default: Article } = await import('#models/article') const registry = await this.app.container.make(JsonApiRegistry) - // Reuse the query-parameter machinery: parse and validate include paths. - // Passing the registry makes validation respect exposeRelationships; - // without it only the model's relations are checked. + // Parse and validate include paths. Passing the registry makes + // validation respect exposeRelationships, as a request would. const params = parseQueryParams({ include: this.include }) validateIncludeTree(Article, params.include, registry) // Preload the include tree, then fetch. The cast is the same variance - // bridge ctx.jsonApi.query() uses internally: Lucid types preload() - // with literal relation names, while include trees work with strings. + // bridge ctx.jsonApi.query() uses: Lucid types preload() with literal + // relation names, while an include tree works with strings. const query = Article.query() applyIncludes(query as unknown as DynamicModelQuery, params.include) const articles = await query - // No request means no route namespace, so turn link generation off + // No request means no route namespace, so turn links off. const document = new DocumentBuilder(registry, params, new LinkBuilder(false)).build(articles) this.logger.log(JSON.stringify(document, null, 2)) @@ -81,16 +80,14 @@ export default class ExportArticles extends BaseCommand { node ace export:articles --include=author,tags ``` -The same pattern works anywhere you have a booted application. Build `params`, either from user input via `parseQueryParams` or by constructing the object directly, preload what the include tree needs, and hand the rows to a `DocumentBuilder`. +The pattern is the same anywhere the app is booted. Build `params`, from user input via `parseQueryParams` or by hand. Preload what the include tree needs. Hand the rows to a `DocumentBuilder`. `build()` accepts a single row, an array, a Lucid paginator, or `null`, plus top-level extras: `build(rows, { meta: { exportedAt }, links: {} })`. -`build()` accepts a single row, an array, a Lucid paginator, or `null`, plus optional top-level extras: `builder.build(rows, { meta: { exportedAt: ... }, links: { ... } })`. +## Links without a request -## Links outside a request +Inside a request, links are namespaced by the route that served it. Outside a request there is no current route, so you choose: -Inside a request, links are namespaced by the route that served it. Outside a request there is no "current route", which leaves you two options: - -- **No links.** `new LinkBuilder(false)`. Usually right for exports and jobs. -- **Anchor to a route group yourself.** Pass the router service and any route name from the group whose URLs you want: +- **No links.** `new LinkBuilder(false)`. Usually right for an export or a job. +- **Anchor to a group.** Pass the router and any route name from the group whose URLs you want: ```ts import router from '@adonisjs/core/services/router' @@ -98,11 +95,11 @@ Inside a request, links are namespaced by the route that served it. Outside a re const links = new LinkBuilder(true, router, 'api.v1.articles.show') ``` - Every generated link now resolves against the `api.v1` group's named routes, exactly as if the document were rendered by a request to that group. The existence checks still apply, and resources without registered routes get no links. + Every link now resolves against the `api.v1` group, as if a request to that group produced the document. The existence checks still apply, so a model with no routes gets no links. -## Deserializing without a request +## Deserialize without a request -Useful for queue-delivered payloads or webhook bodies that carry JSON:API documents: +For a queue payload or a webhook body that carries a JSON:API document: ```ts import { @@ -112,32 +109,28 @@ import { } from '@evoactivity/jsonapi-adonis' const registry = await app.container.make(JsonApiRegistry) -const input = deserializeResourceDocument(Article, registry, payload, { - allowClientIds: false, -}) -await verifyRelatedExist(Article, input.references, registry) // 404-style JsonApiException if missing +const input = deserializeResourceDocument(Article, registry, payload, { allowClientIds: false }) +await verifyRelatedExist(Article, input.references, registry) // 404-style throw if missing const article = await Article.create(input.attributes) ``` -All the write-side error semantics apply (400/403/409, and 404 via `verifyRelatedExist`). Failures throw `JsonApiException`, which carries ready-made error objects. +The same error rules apply (`400`/`403`/`409`, and `404` from `verifyRelatedExist`). A failure throws a `JsonApiException` carrying ready-made error objects. See [Writes](./writes.md#the-write-error-rules). ## Error documents anywhere -`toErrorDocument(error, debug)` is pure. It maps any thrown value to `{ status, body }` where `body` is a spec-compliant errors document. Handy for jobs that report failures in JSON:API shape, or for testing error mappings without a server: +`toErrorDocument(error, debug)` is pure. It maps any thrown value to `{ status, body }`, where `body` is a spec-compliant error document. Useful for a job that reports failures in JSON:API shape, or a test that checks an error mapping with no server: ```ts -import { toErrorDocument } from '@evoactivity/jsonapi-adonis' - const { status, body } = toErrorDocument(error, false) ``` ## Caveats -- **Pagination links need a request.** `first`, `prev`, `next` and `last` are built from the request URL and query string. Without a ctx they come out `null`, though the `meta.page` totals are still emitted. Pass your own via `build(rows, { links: { ... } })` if you need them. -- **`this.ctx` is `undefined` in resource classes** during ctx-less serialization. Write `attributes()` and `meta()` implementations defensively (`this.ctx?.auth...`) if they use it. -- **Boot the app first.** Resource classes are registered in the provider's `ready` phase, and models need the database. In ace commands, set `static options = { startApp: true }`. +- **Pagination links need a request.** `first`/`prev`/`next`/`last` are built from the request URL. Without a ctx they come out `null`, though `meta.page` totals still appear. Pass your own via `build(rows, { links: {} })` if you need them. +- **`this.ctx` is `undefined` in a resource class** during ctx-less serialization. If `attributes()` or `meta()` read it, guard with `this.ctx?.auth...`. +- **Boot the app first.** Resource classes register in the provider's `ready` phase, and models need the database. In an ace command, set `static options = { startApp: true }`. --- -Back to the [reference](./reference.md) · [README](../README.md) +Back to the [Reference](./reference.md) · [README](../README.md) diff --git a/docs/polymorphism.md b/docs/polymorphism.md index dbb94a6..8d53ea3 100644 --- a/docs/polymorphism.md +++ b/docs/polymorphism.md @@ -1,23 +1,25 @@ -# Polymorphic relationships +# Polymorphism -A relationship is polymorphic when its members are not all the same kind of thing. A fan's favourites list holds football teams and rugby teams. An article's attachments are images and videos. A feed mixes posts, photos, and links. One relationship holds several kinds of member. +Every JSON:API resource object and every identifier must carry a concrete `type`. For an ordinary model this is settled: one model, one type. A polymorphic relationship breaks that assumption. It holds rows of several concrete types over one shared id space, so the type can no longer come from the relation's declared target. It has to come from the row. -In JSON:API terms, that means resource linkage whose identifiers carry different `type` values: +This page is about that one problem: naming the concrete type of a row, and checking a claimed type on a write. The package solves it with two hooks on the base resource. The rest is those two hooks, consulted at fixed points. -```jsonc -"favourites": { - "data": [ - { "type": "football-teams", "id": "1" }, - { "type": "rugby-teams", "id": "2" } - ] -} -``` +## The two questions + +The registry answers two questions, and each has one hook behind it. + +| Question | Hook | Registry method | Consulted when | +| ------------------------------ | ---------------------- | ------------------------------- | --------------------------------------------------------------------- | +| What type is _this row_? | `resolveResource(row)` | `resourceForRow` → `typeForRow` | serializing primary data, linkage, `included`, and relationship reads | +| What types may a write _name_? | `subtypes()` | `acceptedTypesFor` | validating a write body and a relationship-endpoint body | -Client-side data libraries expect exactly this shape. [WarpDrive's polymorphism guide](https://warp-drive.io/guides/the-manual/misc/relational-data/features/polymorphism), for instance, is explicit that linkage should carry the concrete type, not an abstract umbrella type, because `type` + `id` is a resource's whole identity and an umbrella type creates a second identity for the same row. +`resolveResource` takes a row and returns the concrete resource for it, usually by reading a discriminator column. `subtypes` returns the family as a list of resource classes. The two look similar but answer opposite questions, and neither can be computed from the other. A row is a value you can inspect, so a function answers it. A write carries a type string and no row yet, checked before any database access, so the answer needs the family as a plain list. -## Single-table inheritance +The rest of this page follows those two hooks through the code. -Databases offer several ways to build the shape, such as one table per type joined by id, or Laravel-style `imageable_type` + `imageable_id` column pairs, which Lucid has no native relations for. This package supports one of them, **single-table inheritance (STI)**. All the types share one table, and a discriminator column says which type each row is, with columns only some types use sitting null on the others: +## The database shape + +The package supports **single-table inheritance (STI)**. Every type in the family shares one table. A discriminator column says which type each row is. Columns that only some types use are null on the others: ``` sports_teams @@ -26,38 +28,20 @@ sports_teams 2 Saracens rugby NULL 312 ``` -With one shared table, the database layer already works. Anything pointing at the family is an ordinary foreign key, a mixed to-many is an ordinary pivot, and plain `belongsTo` / `hasMany` / `manyToMany` declared against the base class work mechanically. Subclasses set `static table` to the shared table and scope their queries to their discriminator value. - -The only thing left to solve is _naming_, and it is a serialization problem, not a Lucid one. Lucid hydrates a relation's rows as the relation's declared target, the base class, which is correct behaviour for an ORM with no concept of a discriminator. The row still carries its discriminator column. Without the declarations on this page, the serializer derived a row's JSON:API type from its class rather than from the row, so base-hydrated rows serialized under the base type, and writes compared incoming identifiers against the one declared target type. Teaching the serializer to ask the row is what the rest of this page describes. - -Declaring nothing is also workable. The base serializes as one `sports-teams` type, the discriminator rides along as an ordinary attribute, and clients branch on it. Nothing in JSON:API forbids that, and for an API that is content to mirror its database it is less machinery. The declarations below exist so the API does not have to mirror the schema. Clients see `football-teams` and `rugby-teams` as if each had its own table, the shared table stays an implementation detail, and storage can be reorganized later without renaming anything a client depends on. - -## Declaring the family - -Lucid's own serialization needs none of what follows. `row.serialize()` emits the columns, the discriminator among them, and that is all anyone expects of it, so with plain Adonis serialization STI simply works. JSON:API asks for more. Every resource object and every identifier must carry a `type`, and the correct type for an STI row is the concrete one. The declarations in this section are that extra effort. They hand the serializer the mapping from a row to its concrete type, which the model definitions alone do not carry. - -The examples below use one family. `SportsTeam` is the base, `FootballTeam` and `RugbyTeam` share its table, and `sport` is the discriminator. +The model side is ordinary Lucid. The base stays unscoped, so a relation that targets it sees the whole family. Each subclass points at the same table and scopes its own queries to its discriminator: ```ts // app/models/sports_team.ts export default class SportsTeam extends BaseModel { static table = 'sports_teams' - /** - * The discriminator value each subclass owns. Null on the base. The - * base stays unscoped, so a relation targeting it can hold the whole - * family. - */ + /** The discriminator value each subclass owns. Null on the base. */ static readonly teamSport: 'football' | 'rugby' | null = null @column() declare name: string @column() declare sport: 'football' | 'rugby' - /** - * Scopes subclass queries to their discriminator, so - * FootballTeam.query() only ever sees football rows. find/findOrFail/ - * first inherit the scope because they build on query(). - */ + /** Scope subclass queries to their discriminator. */ static query>( this: Model, options?: ModelAdapterOptions @@ -68,10 +52,7 @@ export default class SportsTeam extends BaseModel { return query } - /** - * Rows created through a subclass carry its discriminator without the - * caller spelling it out. - */ + /** Stamp the discriminator on rows created through a subclass. */ @beforeCreate() static assignSport(row: SportsTeam) { const sport = (row.constructor as typeof SportsTeam).teamSport @@ -88,15 +69,13 @@ export class RugbyTeam extends SportsTeam { static table = 'sports_teams' static readonly teamSport = 'rugby' as const } - -// app/models/fan.ts -export default class Fan extends BaseModel { - @manyToMany(() => SportsTeam, { pivotTable: 'favourites' }) - declare favourites: ManyToMany -} ``` -`Fan.favourites` is the relation that needs the declarations. It targets the base, so Lucid hydrates its rows as `SportsTeam` and only the discriminator says what each one is. Contrast a direct query. `FootballTeam.query()` hydrates `FootballTeam` instances, the class itself names the type, and those rows serialize as `football-teams` with no help. Giving base-targeted rows the same treatment takes two statics on the base resource. Each resource stays in its own file, scaffolded with `node ace make:jsonapi:resource`, and the subtype resources are ordinary resources: +The package never sees this model code. It only ever calls the two hooks on the resource. Everything above is your own Lucid, and you can shape it however your schema needs, as long as a row can report its own kind. + +## Declaring the resources + +Each concrete type is an ordinary resource: ```ts // app/resources/football_team_resource.ts @@ -112,7 +91,7 @@ export default class RugbyTeamResource extends JsonApiResource { } ``` -The base resource imports them and declares the family: +The base resource adds the two hooks: ```ts // app/resources/sports_team_resource.ts @@ -128,11 +107,7 @@ export default class SportsTeamResource extends JsonApiResource { } ``` -`resolveResource` serves reads. It takes a row and returns its concrete resource, typically by reading the discriminator, and returning `undefined` keeps the row on the base resource. `subtypes` serves writes. It lists the types a relation targeting the base accepts. - -The two look similar but answer opposite questions, and neither can be derived from the other. When serializing, the input is a hydrated row, and the question is which one resource it belongs to, which only a function can answer. When validating a write, the input is a type string from the request body, and no row exists yet because the check runs before any database access, so the question is whether the string belongs to the family, and that needs the family as a list, which cannot be computed out of an opaque function. - -Register all three in the config, the same as any other resource: +Register all three, the same as any other resource: ```ts // config/jsonapi.ts @@ -143,97 +118,25 @@ resources: [ ] ``` -The base also registers its declared subtypes as a safety net. Registration is what gives a direct query its type, so a subtype missing from the map would make `FootballTeam.query()` rows serialize under an auto-derived type, and the safety net prevents that silent degradation when an entry is forgotten. It never overrides you. An explicitly registered resource for a subtype's model always wins over the base's declaration. +Registering the base also registers its `subtypes` as a backup. A subtype needs its own registration so that a direct `FootballTeam.query()` finds a resource and serializes as `football-teams`. If you forget a subtype in the config, the base fills it in. Your explicit registration always wins, so the backup never overrides a resource you registered yourself. -## Families discovered at runtime +## Reads: naming a row -So far the family has been fixed. Football and rugby are known while the code is written, so each subtype gets a model, a resource file, and a config entry. - -Some applications cannot know the family upfront, because the types are made by users. A form builder where users define their own field kinds. A CMS where editors invent content types. A tracker where each workspace declares its own item categories. The rows still live in one table with a discriminator column, since a table per kind would need a migration every time a user invents one, but the discriminator's values are data, created at runtime, different in every installation. There is no moment at which a developer can write `football_team_resource.ts`, because nobody knows what the kinds will be. - -The declarations still work, because both are functions. Nothing forces `subtypes()` to return classes that exist as files, and nothing forces `resolveResource` to pick from a fixed set. A base resource can build concrete resources on demand, keyed on discriminator values that are themselves user data: - -```ts -// app/resources/team_resource.ts -const cache = new Map() - -export function resourceForSport(sport: string) { - let resource = cache.get(sport) - if (!resource) { - // the discriminator value doubles as the JSON:API type, so one - // vocabulary covers the column, the URL, and the documents - resource = class extends TeamResource { - static type = sport - } - cache.set(sport, resource) - } - return resource -} +`typeForRow(row)` is the whole read path. It calls `resourceForRow`, which starts at the model's resource and follows `resolveResource` while one exists: -export default class TeamResource extends JsonApiResource { - static model = () => SportsTeam - static subtypes = () => [...cache.values()] - static resolveResource(row: SportsTeam) { - return resourceForSport(row.sport) - } -} ``` - -Three details make this work. - -- **One class per value, memoized.** The registry caches type strings per class object, and the resolution walk ends when it sees a repeated class, so the same discriminator value must always resolve to the same class. The `Map` provides that identity. -- **`subtypes()` is consulted on every write**, never captured at boot. The moment a new kind lands in the cache, writes naming its type are accepted. Before any kind exists, the accepted set is empty and every write is a 409. -- **Inheriting from the base resource is safe.** The runtime-built class inherits `resolveResource`, and resolving again returns the same memoized class, which ends the walk. It also inherits `exposeRelationships`, `filters`, and any attribute customization, so the family shares the base's behaviour by default. - -How kinds enter the cache is the application's decision, and the choice shows on writes. Filled lazily, as above, a kind's type is only accepted after some row of that kind has been serialized once. Seeded at boot, every known kind is accepted from the first request. Seeding is a preload file that reads the kinds from wherever they are defined, here the distinct discriminator values already in the table: - -```ts -// start/team_kinds.ts -import db from '@adonisjs/lucid/services/db' -import { resourceForSport } from '#resources/team_resource' - -const rows = await db.from('sports_teams').distinct('sport') -for (const { sport } of rows) { - resourceForSport(sport) -} -``` - -Registered in `adonisrc.ts` so it runs at boot, restricted to the web environment: - -```ts -preloads: [{ file: () => import('#start/team_kinds'), environment: ['web'] }] -``` - -The restriction matters. Preloads also run when ace commands boot the app, and on a fresh database the table does not exist until `migration:run` finishes, so a preload that reads it would crash the very command that creates it. Web-only registration keeps the seed out of console commands and tests, where rows are seeded per test anyway. - -A kind created after boot is registered where it is created. The endpoint or service that saves a new kind calls `resourceForSport` as part of the same operation, so the seed covers everything known at boot and the create path covers everything after. - -Runtime kinds cannot have routes registered per type, so their endpoints are wildcard routes over the base model: - -```ts -// GET /api/v1/:type -async index({ jsonApi, params }: HttpContext) { - const teams = await jsonApi.query(SportsTeam).where('sport', params.type).paginate(...) - return jsonApi.render(teams) -} - -// POST /api/v1/:type -async store({ jsonApi, params }: HttpContext) { - const input = await jsonApi.deserialize(SportsTeam, { expectedType: params.type }) - const team = await SportsTeam.create({ ...input.attributes, sport: input.type }) - return jsonApi.render(team, { status: 201 }) -} +resourceForRow(row): + resource = resource for row's model // SportsTeamResource + while resource.resolveResource: + next = resource.resolveResource(row) // FootballTeamResource + if next is undefined or already seen: stop + resource = next + return resource ``` -Reads need nothing special, because every row is named through `resolveResource` regardless of which endpoint served it. Writes lean on two things. Deserializing against an STI base accepts any member of the declared family, and `expectedType` narrows that to the one type the URL names, so a body claiming a different member is a 409. The result carries the validated `type` back, which is also how an endpoint without a type in its URL would decide which discriminator to stamp. +The walk stops on a repeated class, so a hook that resolves to itself cannot loop. For a fixed family the walk is one step: the base resolves to a concrete resource, and the concrete resource has no `resolveResource`, so it ends there. Returning `undefined` from the hook keeps the row on the base resource. -The controllers trust `params.type`, so validate it against the known kinds first and 404 the rest. `expectedType` pins the body to the URL, and it accepts whatever string it is given, so an unvalidated URL segment would let a made-up kind straight through to the row. - -None of this shape is prescribed by the package. The cache, the factory, and the seeding are ordinary application code, and the package only ever calls the two declared functions. The example exists to show how much room those two functions leave. - -## What documents look like - -Rows resolve to concrete types everywhere they appear, in primary data, linkage, `included` (deduplicated under the concrete type), and relationship-endpoint GETs. Sparse fieldsets key on the concrete type (`fields[football-teams]=name`). +Because every row goes through this one function, the concrete type is consistent everywhere a row appears. A `Fan` with mixed favourites serializes each member under its own type, and `included` deduplicates under the concrete type: ```jsonc // GET /fans/1?include=favourites @@ -257,56 +160,20 @@ Rows resolve to concrete types everywhere they appear, in primary data, linkage, } ``` -The relationship endpoint serves the same concrete linkage: - -```jsonc -// GET /fans/1/relationships/favourites -{ - "jsonapi": { "version": "1.1" }, - "data": [ - { "type": "football-teams", "id": "1" }, - { "type": "rugby-teams", "id": "2" }, - ], - "links": { - "self": "/fans/1/relationships/favourites", - "related": "/fans/1/favourites", - }, -} -``` - -The abstract type (`sports-teams`) never appears in a payload. Clients see a family of concrete types over one shared id space, which is what client caches expect of polymorphic data. +The abstract type `sports-teams` never reaches a payload, because `resolveResource` runs before any type is written. Sparse fieldsets key on the concrete type too (`fields[football-teams]=name`). -## Writes +## Writes: checking a claimed type -A relation targeting the base accepts any member of the family, in any mix. Adding a rugby team to favourites that already hold a football team: +A write names a type in the body. The package validates it with `acceptedTypesFor(Model)`, which reads `subtypes`. A model whose resource declares `subtypes` accepts every member of the family and nothing else. Its own abstract type is never accepted, because that type never belongs in a payload. Any other model accepts its single type. -```jsonc -// POST /fans/1/relationships/favourites -{ "data": [{ "type": "rugby-teams", "id": "2" }] } +There are two separate checks, and they fail differently. -// 200 -{ - "jsonapi": { "version": "1.1" }, - "data": [ - { "type": "football-teams", "id": "1" }, - { "type": "rugby-teams", "id": "2" }, - ], - "links": { - "self": "/fans/1/relationships/favourites", - "related": "/fans/1/favourites", - }, -} -``` - -A type outside the family is a `409` naming every acceptable type. The abstract base type is rejected the same way, it never belongs in a payload: +**Membership.** A type outside the family is a `409`, naming the acceptable types. This runs in the deserializer and in the relationship endpoints: ```jsonc -// POST /fans/1/relationships/favourites -{ "data": [{ "type": "referees", "id": "1" }] } - +// POST /fans/1/relationships/favourites with { "data": [{ "type": "referees", "id": "1" }] } // 409 { - "jsonapi": { "version": "1.1" }, "errors": [ { "status": "409", @@ -318,15 +185,12 @@ A type outside the family is a `409` naming every acceptable type. The abstract } ``` -A claimed type the row's discriminator contradicts is a `404`. The family shares one id space, so any id "exists" for any subtype, and the claimed type has to be checked against the row. Row 2 exists, but it is a rugby team, so `football-teams/2` names a resource that does not exist, and an existence check alone would attach it silently: +**Identity.** A type inside the family, but wrong for the row it names, is a `404`. The family shares one id space, so any id exists for some subtype. `verifyRelatedExist` loads the referenced rows and compares each row's real type against the claimed type. Row 2 is a rugby team, so `football-teams/2` names a resource that does not exist: ```jsonc -// POST /fans/1/relationships/favourites -{ "data": [{ "type": "football-teams", "id": "2" }] } - +// POST /fans/1/relationships/favourites with { "data": [{ "type": "football-teams", "id": "2" }] } // 404 { - "jsonapi": { "version": "1.1" }, "errors": [ { "status": "404", @@ -338,68 +202,75 @@ A claimed type the row's discriminator contradicts is a `404`. The family shares } ``` -`verifyRelatedExist` performs the discriminator check, which is why the function takes the registry. +Without the identity check, a mislabelled identifier would attach the wrong row. That is why `verifyRelatedExist` takes the registry: it needs `typeForRow` to learn each row's real type. -## The one place a type cannot be known +## The unloaded belongsTo -Resource linkage for an unloaded belongsTo is normally derived from the bare foreign key, without touching the database. With STI that derivation is impossible, because the discriminator lives on the target row, and the foreign key is just an id. Suppose a stadium belongs to a team: +One case has no answer. Resource linkage for an unloaded belongsTo normally comes from the bare foreign key, with no query. The document builder reads the foreign-key value and pairs it with the relation's declared target type. That works for an ordinary target, because the target type is fixed. -```ts -export default class Stadium extends BaseModel { - @column() declare teamId: number +It cannot work for an STI base. The discriminator lives on the target row, and the foreign key is only an id. The declared target is the base, so a guess would name the abstract type: - @belongsTo(() => SportsTeam, { foreignKey: 'teamId' }) - declare team: BelongsTo -} +```jsonc +"team": { "data": { "type": "sports-teams", "id": "1" } } // wrong: row 1 is a football team ``` -Fetching a stadium on its own leaves `team` unloaded. If the serializer guessed from the foreign key anyway, the id would come from `team_id` and the only type it could name is the relation's declared target, the base: +That guess would fork client caches. The same row would arrive as `sports-teams/1` here and `football-teams/1` everywhere else, so a cache keyed by type and id would hold two records for one row. The builder detects the STI base (the target model's resource declares `subtypes`) and refuses to guess. It omits `data` and keeps the links: ```jsonc -// GET /stadiums/9, as it would look if the guess were made -{ - "data": { - "type": "stadiums", - "id": "9", - "attributes": { "name": "Emirates" }, - "relationships": { - "team": { - // team_id = 1, and the declared target is SportsTeam - "data": { "type": "sports-teams", "id": "1" }, - "links": { - "self": "/stadiums/9/relationships/team", - "related": "/stadiums/9/team", - }, - }, - }, - }, +"team": { + "links": { + "self": "/stadiums/9/relationships/team", + "related": "/stadiums/9/team" + } } ``` -Row 1 is a football team, so the true identity of this resource is `football-teams/1`, and that is what every other document calls it. This one calls it `sports-teams/1`. That one guess causes three problems. +A client that needs the target follows the `related` link, which loads the row and returns the concrete type. Loading or including the relation always gives full concrete linkage. A belongsTo whose target is a concrete subclass, or any non-STI model, keeps foreign-key linkage as before. -- The identity never resolves. No endpoint serves `sports-teams`, so nothing a client does with `sports-teams/1` produces a resource. Following the `related` link works, but it returns `football-teams/1`, which does not match the linkage that pointed at it. -- It forks client caches. A cache keys resources by `type` + `id`, so when the same row arrives through any other path, a favourites list, a direct fetch, it lands as `football-teams/1` and the cache now holds two records for one row, the abstract one empty forever. -- It dead-ends type-keyed client logic. Code that routes from linkage, opening a team page for `football-teams` or `rugby-teams`, has no branch for `sports-teams`. +## Families made at runtime -In a compound document the same guess also violates the spec's full-linkage rule, since the row can sit in `included` under its concrete type while the guessed identifier dangles. +Both hooks are functions, so the family does not have to be fixed in code. When users define their own kinds at runtime, `subtypes()` can return classes from a cache and `resolveResource` can build one on demand, keyed on the discriminator value: -So the package refuses to guess. An unloaded belongsTo targeting an STI base emits the relationship member with **no `data`**, keeping its `links`: +```ts +// app/resources/team_resource.ts +const cache = new Map() -```jsonc -"team": { - "links": { - "self": "/stadiums/9/relationships/team", - "related": "/stadiums/9/team", - }, +export function resourceForSport(sport: string) { + let resource = cache.get(sport) + if (!resource) { + // the discriminator value is also the JSON:API type + resource = class extends TeamResource { + static type = sport + } + cache.set(sport, resource) + } + return resource } + +export default class TeamResource extends JsonApiResource { + static model = () => SportsTeam + static subtypes = () => [...cache.values()] + static resolveResource(row: SportsTeam) { + return resourceForSport(row.sport) + } +} +``` + +Two things make this safe. The `Map` returns one stable class per value, so `resourceForRow` sees a repeated class on the second step and ends the walk. And `subtypes()` reads the cache on every write, so a kind becomes acceptable the moment it enters the cache, not at boot. + +Seed the cache at boot if you want a kind accepted before its first row is serialized: + +```ts +// start/team_kinds.ts, registered as a web-only preload +const rows = await db.from('sports_teams').distinct('sport') +for (const { sport } of rows) resourceForSport(sport) ``` -A client that needs the target follows the `related` link and gets the concrete type from the loaded row. Loading the relation (or including it) always yields full concrete linkage. Relations targeting a concrete subclass, or any non-STI model, keep FK-derived linkage exactly as before. +Keep the seed web-only. Preloads also run when ace commands boot the app, and reading the table before `migration:run` creates it would crash the command that creates it. ## Custom type schemes -Type derivation lives in one overridable method on the resource, consulted for models and rows alike: +Type derivation is one method, used for models and rows alike. Override it to compute types differently, and the registry uses it everywhere, including subtype resolution: ```ts static typeName(): string { @@ -407,27 +278,17 @@ static typeName(): string { } ``` -Override it on a resource to compute types under a different scheme; the registry honours it everywhere, including subtype resolution. - -## Should you design your schema this way? - -Polymorphic schemas have a poor reputation, and the criticism is fair. A relational schema is supposed to declare what the data is and have the database enforce it, and every polymorphic shape gives some of that up. A morph column pair cannot be a real foreign key, an STI table cannot mark a subtype's column `NOT NULL`, and either way rules the database once enforced become application conventions that every program touching the database has to honour by hand. Critics read polymorphism as an ORM convenience imposed on a schema. - -The criticism does not make the need go away. Favourites lists, feeds, and attachments are mixed collections in reality, and a design that refuses polymorphism pays elsewhere, with a pivot table and an endpoint per type, union queries behind every mixed list, and the same feature built several times over. Neither side of the trade is free, so the question is which set of costs fits your data. - -Reach for STI when the types are **variations of one thing**. They share most of their columns, they appear together in the same lists and relationships, and things point at "any of them". Teams that are football, rugby, or cricket teams. Attachments that are images or videos. If you need one relationship that holds several of these types, STI fits. - -STI is also the only workable shape when **the types themselves are user data**. If users define their own kinds at runtime, there is no way to create a table or a model per kind upfront, while a discriminator column absorbs new values without a migration. See [Families discovered at runtime](#families-discovered-at-runtime) for how the resource side keeps up. +## Is STI the right schema? -The costs are real, and they are schema costs, so they outlive any library choice: +STI trades schema guarantees for one shared table, and the trade is real. -- **Sparse columns.** Every subtype's columns exist on every row. A column only rugby teams use is `NULL` on every football team. The table gets wide, and `NOT NULL` stops being expressible for subtype-specific columns, the database cannot say "scrum_wins is required, but only for rugby teams" without check constraints keyed on the discriminator. -- **The schema does not enforce the discriminator.** Nothing stops a query from treating a rugby row as a football team; the id space is shared, so any id "exists" for any subtype. Discipline has to live in the application layer, which is why this package verifies claimed types against the discriminator on writes. -- **Everything shares the table.** Migrations, indexes, and locks affect the whole family. A busy subtype's traffic is every subtype's traffic. -- **One relation cannot span the split.** If you later move a subtype out to its own table, every relation targeting the base breaks. +- **Sparse columns.** Every subtype's columns exist on every row, so `NOT NULL` cannot express a rule that holds for one subtype only. +- **No enforced discriminator.** The database cannot stop a query from reading a rugby row as a football team. The rule lives in the application layer, which is why this package checks the claimed type against the discriminator on writes. +- **Shared table.** Migrations, indexes, and locks affect the whole family. +- **No later split.** Moving a subtype to its own table breaks every relation that targets the base. -If the types share little beyond a name, prefer separate tables and separate endpoints and no polymorphism at all. A relationship that must span genuinely different tables needs the type + id column pair, with the trade-offs above. +Reach for STI when the types are variations of one thing: they share most columns, appear in the same lists, and things point at any of them. Reach for it too when the types are user data, because a discriminator column absorbs new kinds without a migration. When the types share little beyond a name, prefer separate tables and separate endpoints. --- -Next: [Links](./links.md) · [Errors & negotiation](./errors.md) · [Reference](./reference.md) +Next: [Links](./links.md) · [Errors and negotiation](./errors.md) · [Reference](./reference.md) diff --git a/docs/queries.md b/docs/queries.md new file mode 100644 index 0000000..ac00916 --- /dev/null +++ b/docs/queries.md @@ -0,0 +1,130 @@ +# Queries + +Reading is one method, `jsonApi.query(Model)`. It returns a normal Lucid query builder with the request's parameters already applied. This page follows the five parameters through it. + +## One entry point + +`jsonApi.query(Model)` does four things and then hands you the builder: + +```ts +query(model) { + validateIncludeTree(model, params.include) // ?include= + const q = model.query() + applyIncludes(q, params.include) // preload the include tree + applySort(q, params.sort) // ?sort= + applyFilters(q, params.filter) // ?filter[...]= + return q +} +``` + +`params` is the request's query string, already parsed. `jsonApi.params` runs `parseQueryParams` one time and caches the result. It turns the raw `?include=…&sort=…&filter[...]=…` into the object these four functions read. A malformed parameter throws a `400` at that point, before any query runs, so a controller never guards against bad query input. After `query()` returns, `include`, `sort`, and `filter` have narrowed the query, and you chain `.where()`, scopes, and `.paginate()` as usual. + +## `include` + +Clients ask for related resources with `include`. Paths nest with dots and join with commas: + +``` +GET /api/v1/articles/1?include=author,comments.author,tags +``` + +Three things happen: + +1. Each path is validated against the model's relations, and against [`exposeRelationships`](./resources.md#one-visibility-rule-four-call-sites). An unsupported path is a `400` with `source: { parameter: "include" }`. +2. The whole tree is preloaded in one pass, so there are no N+1 queries. +3. The results are flattened into `included`, deduplicated by `(type, id)`. + +If the same user wrote the article and three of its comments, that user is one entry in `included`. Each resource's `relationships` member carries the `{ type, id }` linkage. + +Two details follow from how linkage is built: + +- A `belongsTo` gets linkage even with no preload, because the foreign key already holds the id, at no query cost. +- An unloaded to-many is never reported as empty. It appears with `links` and no `data`, because `data: []` would claim it is empty. The client follows the link to load it. See [Concepts](./concepts.md#a-pointer-not-a-nested-copy). + +All Lucid relation kinds serialize: `belongsTo` and `hasOne` as to-one, and `hasMany`, `manyToMany`, and `hasManyThrough` as to-many. + +## Sparse fieldsets + +`fields[]` lists the members to keep for that type. Per the spec, it filters attributes and relationships together: + +``` +GET /api/v1/articles/1?include=author&fields[articles]=title,author&fields[users]=fullName +``` + +That returns articles with only a `title` attribute and an `author` relationship, and included users with only `fullName`. The names are serialized names, the same names that appear in documents. + +## Sorting + +``` +GET /api/v1/articles?sort=-createdAt,title +``` + +`sort` takes comma-separated attribute names. A `-` prefix means descending. Each name is a serialized attribute name, mapped back to its database column. An unknown name is a `400`. + +## Pagination + +`page[number]` and `page[size]` drive Lucid's paginator through the `jsonApi.page` tuple: + +```ts +const articles = await jsonApi.query(Article).paginate(...jsonApi.page) +return jsonApi.render(articles) +``` + +When the client omits `page[size]`, the size is `defaultPageSize` from `config/jsonapi.ts` (20 by default). A paginated response carries `first`, `prev`, `next`, and `last` links, each keeping the other query parameters of the request. It also carries the current state under `meta`: + +```json +"meta": { + "page": { "number": 2, "size": 10, "total": 47, "lastPage": 5 } +} +``` + +A single, non-paginated response carries a top-level `links.self` instead, equal to the request URL. + +## Filtering + +The spec reserves `filter[...]` but leaves its meaning to the server. This package is strict: nothing is filterable unless the resource declares it. A resource with no `filters` rejects every filter, so a client can never probe an arbitrary column. Declare the parameters on the resource class: + +```ts +import { JsonApiResource, filter } from '@evoactivity/jsonapi-adonis' + +export default class ArticleResource extends JsonApiResource
{ + static type = 'articles' + static model = () => Article + + static filters = { + // ?filter[title]=Hello → where('title', 'Hello') + // ?filter[title]=a,b → whereIn('title', ['a', 'b']) + title: filter.eq(), + + // Map a public name to a column and operator. gt/gte/lt/lte exist. + // ?filter[publishedAfter]=2026-01-01 → where('created_at', '>=', …) + publishedAfter: filter.gte('createdAt'), + + // ?filter[author]=7 → where('author_id', 7), by a belongsTo relation + author: filter.relation('author'), + + // Full control: the Lucid query builder and the raw value. + search: filter.custom((query, value) => { + query.where((q) => q.whereILike('title', `%${value}%`).orWhereILike('body', `%${value}%`)) + }), + } +} +``` + +The rules follow from the code: + +- An undeclared filter name is a `400` with `source: { parameter: "filter[name]" }`, the same strict-input policy as `include` and `sort`. +- `eq` and the comparison filters take serialized attribute names, mapped to columns for you. They default to the filter's own key, so a bare `filter.eq()` needs no argument. +- `eq` and `relation` turn comma-separated values into `whereIn`. A comparison filter (`gt`/`gte`/`lt`/`lte`) takes a single value, and returns `400` for more. +- `filter.relation(name)` needs a belongsTo relation. A wrong name is a programmer error, thrown when the filter runs, not a client `400`. +- A `filter.custom` handler also receives `{ Model, name, ctx }`. `ctx` is the request when filtering runs inside one, so a filter can depend on the viewer. It is `undefined` outside a request. +- Filters compose: `?filter[author]=7&filter[search]=lucid&sort=-createdAt&page[size]=10`. + +The declaration is also the documentation. The resource class is the full list of your API's query surface. + +## Unknown parameters + +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. See [Errors and negotiation](./errors.md#strict-query-parameters). + +--- + +Next: [Scopes](./scopes.md) · [Writes](./writes.md) · [Links](./links.md) · [Reference](./reference.md) diff --git a/docs/reading-data.md b/docs/reading-data.md deleted file mode 100644 index 50d0f68..0000000 --- a/docs/reading-data.md +++ /dev/null @@ -1,280 +0,0 @@ -# Reading data - -How models become resources, and how the read-side query parameters (`include`, `fields`, `sort`, `page`, `filter`) behave. - -## Resources and types - -Every Lucid model can serialize as a JSON:API resource with zero configuration. The defaults come from the model's own metadata: - -- **type** is the kebab-cased table name (`users`, `articles`, `access-tokens`) -- **id** is the primary key, converted to a string (the spec requires string ids) -- **attributes** are the serializable columns, minus the primary key and any belongsTo foreign keys (those are represented as relationships instead). `serializeAs` is respected, and columns marked `serializeAs: null`, like password hashes, never appear. -- **relationships** are the relations defined on the model - -## Customizing a resource - -Create a resource class when you want control over any of the defaults, and register it in `config/jsonapi.ts`: - -```ts -// app/resources/user_resource.ts -import User from '#models/user' -import { JsonApiResource } from '@evoactivity/jsonapi-adonis' - -export default class UserResource extends JsonApiResource { - static model = () => User -} -``` - -```ts -// config/jsonapi.ts -export default defineConfig({ - resources: [() => import('#resources/user_resource')], -}) -``` - -That class above is already valid. `static model` is the only required member; registering without it throws, and everything else falls back to the auto-derived behavior. Inside any instance method, `this.resource` is the Lucid model instance being serialized (typed by the generic) and `this.ctx` is the current HttpContext when serialization happens inside a request, or `undefined` outside one. - -Why is `static model` required when serialization itself doesn't need a resource class? The registry is a map from model class to resource class: serialization starts from a Lucid row, so the model is always the known side, and auto-derivation is just what happens on a map miss. Registering a class means filing it under a key, and `static model` is that key. Without the key the class would be unreachable, so the registry fails loudly instead of silently ignoring a resource you wrote. - -Every claim in this section is pinned by [`tests/unit/resource_customization.spec.ts`](../tests/unit/resource_customization.spec.ts). If the docs and the code ever disagree, that suite fails. - -Here is the full surface: - -| Member | Required | Default | -| ---------------------------- | -------- | -------------------------------------------------------------------- | -| `static model` | Yes | none, the registry throws without it | -| `static type` | No | kebab-cased table name (`access_tokens` → `access-tokens`) | -| `static exposeRelationships` | No | every relation on the model | -| `static filters` | No | none, all `?filter[...]` requests get a 400 | -| `id()` | No | the primary key, as a string | -| `attributes()` | No | serializable columns minus pk, belongsTo FKs and `serializeAs: null` | -| `links()` | No | nothing extra, the generated `self` link stands alone | -| `meta()` | No | no `meta` member | - -### `static type` - -Overrides the resource type everywhere the model appears: primary data, linkage pointers, `included`, and the type clients must send in write requests. - -```ts -export default class UserResource extends JsonApiResource { - static model = () => User - static type = 'people' -} -``` - -### `id()` - -The default returns the primary key as a string. Override it to expose a different public identity, a slug or a prefixed id for example. The override is honoured everywhere: `data.id`, relationship linkage, and `included` all agree, because dedup and pointers go through the same method. - -```ts -id() { - return `u-${this.resource.id}` -} -``` - -Note the id is identity, not decoration. If you override it on a resource that has write endpoints, clients will send this id back and your controllers must be able to look records up by it. - -### `attributes()` - -The default returns every serializable column except the primary key (already in `id`), belongsTo foreign keys (already in `relationships`), and anything marked `serializeAs: null`. Override it to curate the set. `this.pick([...])` selects columns by their serialized names, and computed values are plain properties: - -```ts -attributes() { - return { - ...this.pick(['fullName', 'email']), - initials: this.resource.initials, - } -} -``` - -Sparse fieldsets (`?fields[type]=`) filter whatever this method returns, so computed attributes participate like any other. - -### `links()` - -Whatever you return is merged over the generated links, which means you can add links or replace the generated `self`: - -```ts -links() { - return { canonical: `https://example.com/u/${this.resource.id}` } -} -``` - -The generated `self` survives alongside your additions. Return a `self` key yourself and it wins over the generated one. - -### `meta()` - -Attach per-resource metadata. Returning `undefined` or an empty object omits the `meta` member entirely, so it's safe to make it conditional: - -```ts -meta() { - return { isOwn: this.ctx?.auth?.user?.id === this.resource.id } -} -``` - -### `static exposeRelationships` - -By default every relation defined on the model appears as a relationship member. List the ones you want to expose and the rest disappear from documents: - -```ts -static exposeRelationships = ['author', 'tags'] -``` - -Hidden relations are hidden from `?include=` too. Asking to include one is rejected with a 400, exactly like an include path that does not exist, and no preloading happens for it. This keeps a deliberately hidden relation from being loaded (and paid for) just to be discarded at serialization. - -A hidden relation is also unreachable through the relationship endpoints, for reads and writes both: - -``` -GET /articles/1/comments 404 -GET /articles/1/relationships/comments 404 -PATCH /articles/1/relationships/comments 404 -POST /articles/1/relationships/comments 404 -DELETE /articles/1/relationships/comments 404 -``` - -The status is 404 rather than 403, so a hidden relation cannot be told apart from one that was never defined. A 403 would confirm the relation exists, which is the thing you were hiding. - -The same applies to the `relationships` member of a `POST` or `PATCH` body. A hidden relation there is rejected with the same 400 an unknown member gets. Hiding a relation removes it from the API everywhere: documents, `?include=`, the relationship endpoints, and write bodies. - -### `static filters` - -Declares the `?filter[...]` parameters this resource accepts. Nothing is filterable without it. Covered in depth in [Filtering](#filtering) below. - -## Relationships and included data - -Clients ask for related resources with the `include` parameter. Paths can be nested with dots and combined with commas: - -``` -GET /api/v1/articles/1?include=author,comments.author,tags -``` - -The package validates every path against the model's relations (unsupported paths are a `400` with `source: { parameter: "include" }`, per spec), preloads the whole tree in one pass to avoid N+1 queries, and flattens the results into `included`, deduplicated by `(type, id)`. If the same user wrote the article and three of its comments, they appear once. Each resource's `relationships` member carries the `{ type, id }` linkage. - -A couple of behaviors deserve a mention: - -- A `belongsTo` relationship gets linkage even without preloading. The foreign key already holds the answer, at zero query cost. -- An unloaded to-many relationship is never reported as empty. It appears with `links` only, because `data: []` would be a lie. The spec distinguishes "empty" from "not loaded", and the client can follow the link to find out. - -All Lucid relation kinds serialize: `belongsTo` and `hasOne` as to-one, `hasMany`, `manyToMany` and `hasManyThrough` as to-many. - -## Sparse fieldsets - -Clients can trim responses per resource type. `fields[]` lists the fields to keep, and per the spec it applies to attributes _and_ relationships: - -``` -GET /api/v1/articles/1?include=author&fields[articles]=title,author&fields[users]=fullName -``` - -Returns articles with only a `title` attribute and `author` relationship, and included users with only `fullName`. - -## Sorting and pagination - -``` -GET /api/v1/articles?sort=-createdAt,title&page[number]=2&page[size]=10 -``` - -- `sort` accepts comma-separated attribute names. A `-` prefix means descending. Names are matched against serialized attribute names and mapped to the underlying columns; unknown fields are a `400`. -- `page[number]` and `page[size]` map to Lucid's paginator via `jsonApi.page`. Paginated responses carry `first`, `prev`, `next` and `last` links (which preserve your other query parameters, per spec) and a `meta.page` object with totals. - -## Filtering - -The spec reserves `filter[...]` but leaves its meaning to the server. This package takes a strict, declarative stance: nothing is filterable unless the resource says so. Declare filters on the resource class: - -```ts -import { JsonApiResource, filter } from '@evoactivity/jsonapi-adonis' - -export default class ArticleResource extends JsonApiResource
{ - static type = 'articles' - static model = () => Article - - static filters = { - // ?filter[title]=Hello → where('title', 'Hello') - // ?filter[title]=a,b → whereIn('title', ['a', 'b']) - title: filter.eq(), - - // Map a public name to an attribute + operator. - // gt / gte / lt / lte are all available. - // ?filter[publishedAfter]=2026-01-01 → where('created_at', '>=', …) - publishedAfter: filter.gte('createdAt'), - publishedBefore: filter.lte('createdAt'), - - // Filter by a belongsTo relationship's id: - // ?filter[author]=7 → where('author_id', 7) - author: filter.relation('author'), - - // Full control: you get the Lucid query builder and the raw value - search: filter.custom((query, value) => { - query.where((q) => q.whereILike('title', `%${value}%`).orWhereILike('body', `%${value}%`)) - }), - - // Handlers also receive { Model, name, ctx }. ctx is the request - // when filtering runs inside one, so a filter can depend on the - // viewer; it is undefined on the low-level path outside a request. - mine: filter.custom((query, _value, { ctx }) => { - query.where('author_id', ctx!.auth.user!.id) - }), - } -} -``` - -The rules: - -- An undeclared filter name is a `400` with `source: { parameter: "filter[name]" }`. This is the same strict-input policy as `include` and `sort`. A resource with no `filters` rejects all filtering, and clients can never probe arbitrary columns. -- Attribute names in `filter.eq()` and the comparison filters are serialized names, mapped to database columns for you. They default to the filter's own key, hence the bare `filter.eq()`. -- Comma-separated values become `whereIn` for `eq` and `relation`. Comparison filters accept a single value only and return `400` otherwise. -- Filters compose with everything else: `?filter[author]=7&filter[search]=lucid&sort=-createdAt&page[size]=10`. -- The declaration doubles as documentation. The resource class _is_ the list of what your API's query surface supports. - -## Scopes on reads - -Filters are client input. Visibility is not: some rows a client must never see, whatever it asks for. Express that with Lucid model scopes, applied at read time, at the call site, so it stays a deliberate decision on every endpoint rather than hidden magic. - -Define the rule once, on the model, as a Lucid scope: - -```ts -import { BaseModel, scope } from '@adonisjs/lucid/orm' - -class Comment extends BaseModel { - static published = scope((query) => query.where('published', true)) -} -``` - -Apply it to the primary data with Lucid's own `withScopes()`, and to included relations with `withPreloadScopes()`, keyed by the model's relations: - -```ts -const articles = await jsonApi - .query(Article) - .withScopes((scopes) => scopes.published()) // the articles themselves - .withPreloadScopes({ - comments: (scopes) => scopes.published(), // ?include=comments - author: (scopes) => scopes.active(), // ?include=author - }) - .paginate(...jsonApi.page) - -return jsonApi.render(articles) -``` - -The map is **fully typed**: keys autocomplete to `Article`'s relations, and each callback's `scopes` is the related model's scope bag, exactly like `withScopes()`. A wrong relation name or a scope that model does not define is a compile error. - -For nested includes, give the value an object with a `preload` of its own, typed to the next model down: - -```ts -.withPreloadScopes({ - seasons: { - scope: (scopes) => scopes.visible(), // scopes: Season's - preload: { - episodes: (scopes) => scopes.visible(), // scopes: Episode's - }, - }, -}) -``` - -- `withScopes()` is Lucid's own; it constrains the root query. Nothing library-specific. -- `withPreloadScopes()` is what this package adds. The include preloads are built for you from `?include=`, so you cannot reach them at the call site; this constrains them. Each callback is the exact shape of a `withScopes()` callback, so you reuse the related model's own named scopes rather than re-expressing the rule. -- **Structural, typed at every level.** An entry is either a bare callback (scope that relation) or `{ scope?, preload? }` to also constrain deeper includes. Scopes apply along the path you write, so a relation on one branch never leaks to a same-named relation on another. A relation with no entry is left unconstrained. -- **Order in the chain does not matter.** Preload constraints run when Lucid loads the relation, at execution, so `withPreloadScopes()` may come before or after other builder calls. - -This is deliberately explicit and per-query. Visibility is a security concern, and a per-endpoint decision keeps it in plain sight in the code, rather than buried in a resource default that a new endpoint silently inherits or silently forgets. When several endpoints share a rule, factor the map into a shared helper; do not hide it. - ---- - -Next: [Writing data](./writing-data.md) · [Polymorphism](./polymorphism.md) · [Links](./links.md) · [Errors & negotiation](./errors.md) · [Reference](./reference.md) diff --git a/docs/reference.md b/docs/reference.md index 8679e74..210d1a0 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -2,26 +2,24 @@ ## The `jsonApi` context helper -Everything hangs off the `jsonApi` context property, installed by the provider. Destructure it as `{ jsonApi }: HttpContext` or use `ctx.jsonApi`, whichever you prefer: - -| Member | What it does | -| ------------------------------------------ | ---------------------------------------------------------------------------------------- | -| `params` | Parsed `include` / `fields` / `sort` / `page` / `filter` (throws 400 on malformed input) | -| `page` | `[number, size]` tuple for `query.paginate(...)` | -| `query(Model)` | `Model.query()` with include-tree preloads, sorting and declared filters applied | -| `render(rows, { meta?, links?, status? })` | Build the document, set media type (and `Location` on 201) | -| `serialize(rows, extras?)` | Build the document without touching the response (pure) | -| `deserialize(Model, { expectedId? })` | Request body → `{ attributes, toMany, references }` | -| `syncToMany(row, toMany)` | Apply deserialized to-many relationships after save | -| `renderRelationship(row, name)` | Linkage document for `GET …/relationships/:name` | -| `updateRelationship(row, name, action)` | Apply a relationship write (`'replace' \| 'add' \| 'remove'`) | -| `renderRelated(row, name)` | Document of the related resources for `GET …/:name` | -| `handlesErrors()` | Whether this request's errors should render as JSON:API documents | -| `links` | The request's `LinkBuilder` (rarely needed directly) | - -The builder returned by `query(Model)` is a normal Lucid query builder: chain `withScopes()` to constrain the primary data and `withPreloadScopes()` to constrain included relations. See [Scopes on reads](./reading-data.md#scopes-on-reads). - -Lower-level building blocks (`DocumentBuilder`, `JsonApiRegistry`, `parseQueryParams`, `deserializeResourceDocument`, `toErrorDocument`, …) are all exported from `@evoactivity/jsonapi-adonis` if you need to assemble custom behavior. See [Low-level building blocks](./low-level.md) for how to use them outside a request. +The provider installs `jsonApi` on every HttpContext. Destructure it as `{ jsonApi }: HttpContext`, or read `ctx.jsonApi`: + +| Member | What it does | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `params` | Parsed `include` / `fields` / `sort` / `page` / `filter` (throws `400` on bad input) | +| `page` | `[number, size]` tuple for `query.paginate(...)` | +| `query(Model)` | `Model.query()` with include preloads, sorting, and declared filters applied | +| `render(rows, { meta?, links?, status? })` | Build the document, set the media type, and the `Location` header on `201` | +| `serialize(rows, extras?)` | Build the document without touching the response (pure) | +| `deserialize(Model, { expectedId?, expectedType? })` | Request body → `{ id?, type, attributes, toMany, references }` | +| `syncToMany(row, toMany)` | Apply deserialized to-many relationships after save | +| `renderRelationship(row, name)` | Linkage document for `GET …/relationships/:name` | +| `updateRelationship(row, name, action)` | Apply a relationship write (`'replace' \| 'add' \| 'remove'`) | +| `renderRelated(row, name)` | Document of the related resources for `GET …/:name` | +| `handlesErrors()` | Whether this request's errors should render as JSON:API documents | +| `links` | The request's `LinkBuilder` (rarely needed directly) | + +`query(Model)` returns a normal Lucid builder. Chain `withScopes()` for the root query and `withPreloadScopes()` for the include preloads. See [Scopes](./scopes.md). The lower-level exports (`DocumentBuilder`, `JsonApiRegistry`, `parseQueryParams`, `deserializeResourceDocument`, `toErrorDocument`) let you assemble custom behavior. See [Building blocks](./low-level.md). ## Configuration @@ -30,7 +28,7 @@ Lower-level building blocks (`DocumentBuilder`, `JsonApiRegistry`, `parseQueryPa import { defineConfig } from '@evoactivity/jsonapi-adonis' export default defineConfig({ - /** Resource classes; models without one are auto-derived */ + /** Resource classes; a model without one is auto-derived */ resources: [() => import('#resources/article_resource')], /** Generate links from named routes; false disables links (default true) */ @@ -42,7 +40,7 @@ export default defineConfig({ /** Accept client-generated ids on create (default false, which means 403) */ allowClientIds: false, - /** When errors render as JSON:API documents (defaults to auto-detection) */ + /** When errors render as JSON:API documents (default: auto-detection) */ // errorDetection: (ctx) => ctx.request.url().startsWith('/api/'), }) ``` @@ -55,23 +53,23 @@ node ace make:jsonapi:resource article --relationships # + relationship-endpoin node ace make:jsonapi:resource article --no-controller # resource class only node ace make:jsonapi:resource article --routes # also register the routes -node ace make:jsonapi:controller comment # controllers only, no resource -node ace make:jsonapi:controller comment -r --routes # class (auto-derived resource) +node ace make:jsonapi:controller comment # controllers only, no resource +node ace make:jsonapi:controller comment -r --routes # relationships controller + routes ``` -For `article`, `make:jsonapi:resource` creates `app/resources/article_resource.ts` (type `articles`, with commented-out attribute and filter customization hooks) and `app/controllers/articles_controller.ts` with index/show/store/update/destroy, ready to run. With `--relationships` it also creates `article_relationships_controller.ts`, serving the `/relationships/:relation` endpoints. +`make:jsonapi:resource article` writes `app/resources/article_resource.ts` (type `articles`, with commented-out attribute and filter hooks) and `app/controllers/articles_controller.ts` (index/show/store/update/destroy). With `--relationships` it also writes `article_relationships_controller.ts` for the `/relationships/:relation` endpoints. -Use `make:jsonapi:controller` when the auto-derived resource is all you need. It generates the controllers without a resource class. +Use `make:jsonapi:controller` when the auto-derived resource is enough. It writes only the controllers. -With `--routes`, the command appends a ready-made `router.jsonApiResource(...)` group to `start/routes.ts`, skipping if the type is already registered. Move it inside your versioned API group if you have one. Without the flag, the registration snippets are printed for you to paste. +With `--routes`, the command appends a `router.jsonApiResource(...)` group to `start/routes.ts`, skipping the append when the type is already registered. Move the group inside your versioned API group. Without the flag, the registration snippet is printed for you to paste. Both commands take `-f` (`--force`) to overwrite existing files. ## Selecting routes -`router.jsonApiResource(type, controllers, options)` registers every route the given controllers support. The third `options` argument narrows that with two independent lists. `only` selects resource routes; `relationshipsOnly` selects relationship routes. Each token matches its controller method name. +`router.jsonApiResource(type, controllers, options)` registers every route the given controllers have. The third argument narrows that with two independent lists. `only` selects resource routes. `relationshipsOnly` selects relationship routes. Each token is a controller method name. `only` tokens (the `resource` controller): -| Token | Method + path | +| Token | Method and path | | --------- | ---------------------- | | `index` | `GET /articles` | | `store` | `POST /articles` | @@ -81,7 +79,7 @@ With `--routes`, the command appends a ready-made `router.jsonApiResource(...)` `relationshipsOnly` tokens (the `relationships` controller): -| Token | Method + path | +| Token | Method and path | | --------- | ---------------------------------------------- | | `show` | `GET /articles/:id/relationships/:relation` | | `replace` | `PATCH /articles/:id/relationships/:relation` | @@ -89,7 +87,7 @@ With `--routes`, the command appends a ready-made `router.jsonApiResource(...)` | `remove` | `DELETE /articles/:id/relationships/:relation` | | `related` | `GET /articles/:id/:relation` | -Omit a list and every route on that axis registers; pass it and only the listed tokens do. The two are independent, so subsetting one leaves the other whole. To keep all resource routes but only the relationship reads: +Omit a list, and every route on that axis registers. Pass it, and only the listed tokens register. The two lists are independent, so narrowing one leaves the other whole. To keep every resource route but only the relationship reads: ```ts router.jsonApiResource( @@ -104,4 +102,8 @@ router.jsonApiResource( ## Roadmap -- **[Atomic Operations](https://jsonapi.org/ext/atomic/)**, the official JSON:API extension for performing multiple writes in a single request, applied in one transaction. Either every operation succeeds or none do. This is also the planned answer for the bulk-write cases individual endpoints handle awkwardly, like clearing or re-parenting a `hasMany` relationship (rejected with `403` today), which decomposes cleanly into explicit per-child operations inside one atomic request. +- **[Atomic Operations](https://jsonapi.org/ext/atomic/)**, the official extension for several writes in one request, applied in one transaction. Either every operation succeeds or none do. This is the planned home for the bulk-write cases a single endpoint handles awkwardly, like clearing or re-parenting a `hasMany` (a `403` today), which break down cleanly into per-child operations inside one atomic request. + +--- + +Back to the [README](../README.md) · [Concepts](./concepts.md) · [Getting started](./getting-started.md) diff --git a/docs/resources.md b/docs/resources.md new file mode 100644 index 0000000..8682b14 --- /dev/null +++ b/docs/resources.md @@ -0,0 +1,156 @@ +# Resources + +A resource is the rule for turning one Lucid model into a JSON:API resource object. The rule has a default built from Lucid metadata. You write a resource class only to change part of it. + +## From row to resource object + +When the builder serializes a row, it runs a fixed sequence: + +1. Find the resource class for the row. +2. Read `id()`, the primary key as a string. +3. Read `attributes()`, the serializable columns. +4. Drop any attribute the request's sparse fieldset excludes. +5. Build `relationships` from the model's relations. +6. Merge the generated links with `links()`, and add `meta()`. + +Every step has a default, so a model with no resource class already produces a correct resource object. A resource class overrides one or more steps. + +## The defaults + +- **type** is the kebab-cased table name (`users`, `articles`, `access-tokens`). +- **id** is the primary key, converted to a string. The spec requires string ids. +- **attributes** are the serializable columns, minus two sets: the primary key, and every belongsTo foreign key. A column marked `serializeAs: null`, like a password hash, is already gone, because Lucid never serializes it. +- **relationships** are the relations defined on the model. + +The two exclusions from attributes are not arbitrary. The primary key is already in `id`. A belongsTo foreign key is already in `relationships` as a pointer, so repeating it as an attribute would state the same fact twice. + +## Auto-derivation is the default, applied + +A model with no registered resource still gets one. On a lookup miss, the registry makes an anonymous resource class bound to that model and caches it. So "no resource class" is not a separate code path. It is the default resource, built on demand. Writing a resource class replaces that default for one model. + +## Writing a resource class + +```ts +// app/resources/user_resource.ts +import User from '#models/user' +import { JsonApiResource } from '@evoactivity/jsonapi-adonis' + +export default class UserResource extends JsonApiResource { + static model = () => User +} +``` + +Register it in `config/jsonapi.ts`: + +```ts +export default defineConfig({ + resources: [() => import('#resources/user_resource')], +}) +``` + +The class above is valid as written. `static model` is the only required member, and everything else falls back to the default. It is required because the registry files each class under its model, and serialization always starts from a row and looks the resource up by the row's model. A class with no `static model` has no key, so the registry throws instead of silently ignoring a class you wrote. + +Inside a method, `this.resource` is the row, typed by the generic. `this.ctx` is the current HttpContext inside a request, or `undefined` outside one. + +## The members + +The members come in two kinds, static members and instance methods. The static members configure the mapping from model to type, and the registry reads them off the class, often with no row in hand: to derive a type name, to list a family on a write, or to pick a resource for a row. The instance methods serialize one row, so they run on an instance that holds `this.resource` (the row) and `this.ctx` (the request). For the same reason `static resolveResource` takes the row as an argument instead of reading `this.resource`. It chooses which resource to build, before any instance exists. + +| Member | Kind | Required | Default | +| ---------------------------- | -------- | -------- | ----------------------------------------------------------------------------- | +| `static model` | static | Yes | none, the registry throws without it | +| `static type` | static | No | kebab-cased table name (`access_tokens` → `access-tokens`) | +| `static exposeRelationships` | static | No | every relation on the model | +| `static filters` | static | No | none, every `?filter[...]` request gets a `400` | +| `static subtypes` | static | No | none, declares an STI family (see [Polymorphism](./polymorphism.md)) | +| `static resolveResource` | static | No | none, maps a row to its concrete resource ([Polymorphism](./polymorphism.md)) | +| `id()` | instance | No | the primary key, as a string | +| `attributes()` | instance | No | serializable columns minus pk, belongsTo FKs, and `serializeAs: null` | +| `links()` | instance | No | nothing extra, the generated `self` link stands alone | +| `meta()` | instance | No | no `meta` member | + +### `static type` + +Sets the type everywhere the model appears: primary data, linkage pointers, `included`, and the type a client must send in a write. + +```ts +static type = 'people' +``` + +### `id()` + +Returns the public id. Override it to expose a slug or a prefixed id instead of the primary key: + +```ts +id() { + return `u-${this.resource.id}` +} +``` + +The override applies everywhere at once, because `data.id`, linkage, and `included` all read this one method. The id is identity, not decoration. If you override it on a resource with write endpoints, clients send this id back, so your controllers must be able to find records by it. + +### `attributes()` + +Returns the attribute members. The default is every serializable column minus the primary key, the belongsTo foreign keys, and `serializeAs: null` columns. Override it to curate the set. `this.pick([...])` selects columns by their serialized names, and a computed value is a plain property: + +```ts +attributes() { + return { + ...this.pick(['fullName', 'email']), + initials: this.resource.initials, + } +} +``` + +A sparse fieldset filters whatever this method returns, so a computed attribute behaves like any column. See [Queries](./queries.md#sparse-fieldsets). + +### `links()` + +Whatever you return is merged over the generated links: + +```ts +links() { + return { canonical: `https://example.com/u/${this.resource.id}` } +} +``` + +The generated `self` stays unless you return your own `self`, which then wins. + +### `meta()` + +Returns per-resource metadata, or `undefined` to omit the member. An empty object omits it too, so the method can be conditional: + +```ts +meta() { + return { isOwn: this.ctx?.auth?.user?.id === this.resource.id } +} +``` + +### `static exposeRelationships` + +Lists the relations to show. The rest disappear: + +```ts +static exposeRelationships = ['author', 'tags'] +``` + +## One visibility rule, four call sites + +Whether a relation is visible is decided by one function, `isRelationExposed`. It returns false for a `serializeAs: null` relation, or for a relation left out of `exposeRelationships`. Four separate places call that one function, so a hidden relation is hidden in all of them with no extra flags: + +| Call site | A hidden relation gets | +| ----------------------- | ---------------------- | +| `?include=` validation | `400` | +| Serialization | absent from documents | +| Relationship endpoints | `404` | +| Write-body deserializer | `400` | + +The relationship endpoints return `404`, not `403`. A `403` would confirm the relation exists, which is the fact you were hiding. A `404` reads the same as a relation that was never defined. + +### `static filters` + +Declares the `?filter[...]` parameters this resource accepts. Nothing is filterable without it. See [Queries](./queries.md#filtering). + +--- + +Next: [Queries](./queries.md) · [Scopes](./scopes.md) · [Writes](./writes.md) · [Reference](./reference.md) diff --git a/docs/scopes.md b/docs/scopes.md new file mode 100644 index 0000000..e14949f --- /dev/null +++ b/docs/scopes.md @@ -0,0 +1,63 @@ +# Scopes + +Filters are client input. Visibility is not. Some rows a client must never see, whatever it asks for: a draft, another user's private data, a soft-deleted row. You express that with Lucid scopes, applied at read time, at the call site. Keeping it at the call site is deliberate. Visibility is a security decision, and a per-endpoint decision stays visible in the code instead of hiding in a resource default that a new endpoint inherits or forgets by accident. + +## Two levels, two methods + +`jsonApi.query(Model)` gives back a Lucid builder, and a query has two parts to constrain: + +- **The root query** — the articles themselves. Constrain it with Lucid's own `withScopes()`. Nothing in this package is involved. +- **The include preloads** — the comments, authors, and tags pulled in by `?include=`. This package builds those preloads for you from the request, so you cannot reach them at the call site. `withPreloadScopes()` is how you constrain them. + +```ts +const articles = await jsonApi + .query(Article) + .withScopes((scopes) => scopes.published()) // the articles + .withPreloadScopes({ + comments: (scopes) => scopes.published(), // ?include=comments + author: (scopes) => scopes.active(), // ?include=author + }) + .paginate(...jsonApi.page) + +return jsonApi.render(articles) +``` + +Define each rule once, on the model, as an ordinary Lucid scope: + +```ts +class Comment extends BaseModel { + static published = scope((query) => query.where('published', true)) +} +``` + +## How `withPreloadScopes` reaches the preloads + +`jsonApi.query()` builds the include preloads from `?include=`, and it attaches an empty scope tree to the query at the same time. `withPreloadScopes()` merges your scopes into that tree. When Lucid runs the preload for a relation, this package reads the tree and applies the matching scope to the preload's own query. + +Two properties come out of that timing: + +- **Order does not matter.** The tree is read at execution, not when you call the method, so `withPreloadScopes()` can sit before or after other builder calls. +- **It is fully typed.** The map keys autocomplete to the model's relations. Each callback's `scopes` is the related model's set of scopes, exactly like `withScopes()`. A wrong relation name, or a scope that model does not define, is a compile error. + +## Nested includes + +A nested include takes an object with a `preload` of its own, typed to the next model down: + +```ts +.withPreloadScopes({ + seasons: { + scope: (scopes) => scopes.visible(), // scopes: Season's + preload: { + episodes: (scopes) => scopes.visible(), // scopes: Episode's + }, + }, +}) +``` + +An entry is either a bare callback (scope that relation) or `{ scope?, preload? }` (also constrain deeper includes). A scope applies only along the path you write, so a relation on one branch never leaks to a same-named relation on another branch. A relation with no entry stays unconstrained. + +When several endpoints share a rule, move the map into a shared helper. Do not hide it in a default. + +--- + +Next: [Writes](./writes.md) · [Polymorphism](./polymorphism.md) · [Links](./links.md) · [Reference](./reference.md) diff --git a/docs/what-is-jsonapi.md b/docs/what-is-jsonapi.md deleted file mode 100644 index 8c6410e..0000000 --- a/docs/what-is-jsonapi.md +++ /dev/null @@ -1,86 +0,0 @@ -# What is JSON:API? - -[JSON:API](https://jsonapi.org) is a specification for building JSON APIs. It defines how resources, relationships, errors and query parameters look on the wire, so servers and clients written by different teams, in different languages, interoperate without custom glue code. It has been around since 2013, is stable at version 1.1, and has mature implementations on both sides of the wire. - -The rest of this page covers the questions that usually come up. - -## What problem does it solve? - -Every API team ends up designing the same things: how to shape a record, how to embed related records, how to paginate, how to report errors, what the query parameters for sorting and field selection look like. None of those decisions make your product better. They're pure convention, and yet every bespoke API relitigates them, documents them, and then writes custom client code for the result. - -[JSON:API](https://jsonapi.org) is those decisions, made once, written down carefully, and versioned. You point at the spec instead of writing your own, and both sides of the wire get to reuse existing tooling. - -## What does a response look like? - -Here's "one article, with its author": - -```json -{ - "jsonapi": { "version": "1.1" }, - "data": { - "type": "articles", - "id": "1", - "attributes": { "title": "Hello JSON:API", "body": "..." }, - "relationships": { - "author": { "data": { "type": "users", "id": "7" } } - }, - "links": { "self": "/api/v1/articles/1" } - }, - "included": [{ "type": "users", "id": "7", "attributes": { "fullName": "Alice" } }] -} -``` - -Every record is a resource with a `type` and a string `id`. Its fields live under `attributes`. Its connections to other resources live under `relationships`, as `{ type, id }` pointers. Related records the client asked for arrive in the flat `included` array, each exactly once. - -## Why not just nest related records directly? - -Nesting is what everyone reaches for first: `article.author` is an object, `article.comments` is an array of objects with their own nested `author`. It reads nicely in a code sample and falls apart at scale, for three reasons. - -**Duplication.** If Alice wrote the article and ten of its comments, a nested response serializes Alice eleven times. JSON:API sends her once, in `included`, and everything else points at `users:7`. - -**Identity.** A nested object has no address. When the client receives Alice embedded in three different places and she updates her name, which copies does it patch? With `{ type, id }` pointers there is exactly one Alice, so client-side caches can normalize records and keep every view consistent for free. - -**Ambiguity.** In a nested response, what does `"comments": []` mean? No comments, or comments not loaded? JSON:API distinguishes them: an empty relationship has `data: []`, an unloaded one has links and no `data`. Cycles (an article whose comments point back at the article) also stop being a serialization problem, because pointers don't recurse. - -## Isn't it verbose to consume? - -If you read the documents raw, yes. This is the complaint as most people meet it: - -```js -// a bespoke API -const name = response.data.author.name - -// raw JSON:API (response.data is axios, .data.data is the document) -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) -const name = author.attributes.fullName -``` - -Nobody wants to write `response.data.data.attributes.title` and hand-search `included` in every component. The answer is that you don't. Because the shape is identical on every compliant API, the flattening code is a library, not something you write: - -```js -import { Jsona } from 'jsona' - -const article = new Jsona().deserialize(response.data) -article.title // attributes are flattened -article.author.fullName // relationships resolved from included -``` - -One deserializer call turns any document from any JSON:API server back into the plain nested objects you wanted, with the relationships already stitched together. The deep paths still exist, but only inside a package you install. - -That's the trade the format makes on purpose. A bespoke API gives you terse access paths at the cost of parsing code that is different for every endpoint of every API you consume. JSON:API makes the raw paths uniform and boring, precisely so that one generic library can erase them everywhere. Terse-but-unique loses to verbose-but-identical the moment tooling enters the picture. - -And when someone means the payload itself is verbose: the repeated envelope keys are what gzip is best at, and for relationship-heavy data the deduplication usually wins outright. Sending Alice once instead of eleven times saves more bytes than `attributes` wrappers cost. Naive nesting is the verbose format; it hides the verbosity in duplication. - -## How do API consumers benefit? - -Libraries that already speak the format, on day one. Options like `jsona`, `Kitsu`, `jsonapi-react` plus typed document definitions in `jsonapi-typescript`. On mobile there are JSON:API clients for Swift and Kotlin eg `swift-jsonapi`, `Spraypaint`. All of them get pagination, includes, sparse fieldsets and error handling right, because those behave identically on every compliant API. - -## Where does @evoactivity/jsonapi-adonis fit? - -It implements the server side of the spec for AdonisJS, using your Lucid models as the source of truth: serialization, includes, sparse fieldsets, sorting, filtering, pagination, writes, relationship endpoints, error documents and content negotiation. The rest of these docs cover each piece. - ---- - -Next: [Reading data](./reading-data.md) · [Writing data](./writing-data.md) · [Links](./links.md) · [Errors & negotiation](./errors.md) · [Reference](./reference.md) diff --git a/docs/writes.md b/docs/writes.md new file mode 100644 index 0000000..d7f7c0a --- /dev/null +++ b/docs/writes.md @@ -0,0 +1,114 @@ +# Writes + +Creating, updating, and deleting rows from JSON:API request documents, plus the relationship endpoints. + +## Deserializing a request + +A write request wraps the record in a resource document: + +```json +POST /api/v1/articles +Content-Type: application/vnd.api+json + +{ + "data": { + "type": "articles", + "attributes": { "title": "Hello", "body": "..." }, + "relationships": { + "author": { "data": { "type": "users", "id": "7" } }, + "tags": { "data": [{ "type": "tags", "id": "1" }] } + } + } +} +``` + +`jsonApi.deserialize(Model)` turns that into shapes Lucid can use. It returns `{ id?, type, attributes, toMany, references }`: + +- **attributes** uses model property names, mapped back from serialized names. An unknown attribute is dropped, so your validator stays the only gate on input. A to-one relationship becomes a foreign key here, so `author` arrives as `authorId`. +- **toMany** is a map of relation name to id list, for after the save. +- **references** is every related id the body named, which `deserialize` checks for existence before it returns. A missing id is a `404`. + +```ts +async store({ jsonApi }: HttpContext) { + const input = await jsonApi.deserialize(Article) + // input.attributes === { title: 'Hello', body: '...', authorId: '7' } + const payload = await createArticleValidator.validate(input.attributes) + const article = await Article.create(payload) + // input.toMany === { tags: ['1'] } + await jsonApi.syncToMany(article, input.toMany) + return jsonApi.render(article, { status: 201 }) // sets the Location header +} + +async update({ jsonApi, params }: HttpContext) { + const article = await Article.findOrFail(params.id) + const input = await jsonApi.deserialize(Article, { expectedId: String(article.id) }) + article.merge(await updateArticleValidator.validate(input.attributes)) + await article.save() + await jsonApi.syncToMany(article, input.toMany) + return jsonApi.render(article) +} +``` + +`syncToMany` applies the to-many relationships after the save. A `manyToMany` relation is synced. A `hasMany` relation adopts the listed children by reassigning their foreign key. + +## The write error rules + +`deserialize` enforces the spec's error rules before your controller runs: + +| Situation | Response | +| --------------------------------------------------------------------- | ------------- | +| Missing `data`, a non-string `type`, or a malformed identifier | `400` | +| `data.type` is not accepted by this endpoint | `409` | +| `data.id` missing on update, or does not match the URL (`expectedId`) | `400` / `409` | +| Client sends an `id` on create, and `allowClientIds` is off | `403` | +| A referenced related resource does not exist | `404` | + +A relation hidden by [`exposeRelationships`](./resources.md#one-visibility-rule-four-call-sites) is unknown here too. A `relationships` member that names one gets the same `400` as an unknown member, so hiding a relation also closes the write-body path. + +## Relationship endpoints + +The spec defines URLs for editing a relationship on its own, without touching the resources on either end. `jsonApiResource` registers them when you give it a `relationships` controller: + +| Route | Meaning | +| --------------------------------------- | -------------------------------------- | +| `GET /articles/1/relationships/tags` | Read the linkage (`[{ type, id }, …]`) | +| `PATCH /articles/1/relationships/tags` | Replace all members | +| `POST /articles/1/relationships/tags` | Add members, never duplicating | +| `DELETE /articles/1/relationships/tags` | Remove the named members | +| `GET /articles/1/tags` | The related resources themselves | + +Each action is one line, delegating to the helper: + +```ts +export default class ArticleRelationshipsController { + async show({ jsonApi, params }: HttpContext) { + const article = await Article.findOrFail(params.id) + return jsonApi.renderRelationship(article, params.relation) + } + async replace({ jsonApi, params }: HttpContext) { + const article = await Article.findOrFail(params.id) + return jsonApi.updateRelationship(article, params.relation, 'replace') + } + // add → 'add', remove → 'remove', related → renderRelated(...) +} +``` + +### What each relation kind accepts on write + +Reads work for every kind. Writes branch on the relation kind, and the branches come straight from the code: + +| Relation kind | `PATCH` (replace) | `POST` (add) | `DELETE` (remove) | +| ---------------- | ----------------- | ------------ | ----------------- | +| `belongsTo` | yes | `405` | `405` | +| `manyToMany` | yes | yes | yes | +| `hasMany` | `403` | yes | `403` | +| `hasOne` | `403` | `403` | `403` | +| `hasManyThrough` | `403` | `403` | `403` | + +A `hasMany` refuses full replacement and removal on purpose. The spec lets a server refuse them, and the normal way to move a child is through the child's own belongsTo. A `hasManyThrough` is derived, so it is read-only. See [Links](./links.md#relationship-links-and-concurrent-edits) for why deltas beat full replacement. + +All five routes obey [`exposeRelationships`](./resources.md#one-visibility-rule-four-call-sites). A relation the resource hides returns `404` here too, so registering this controller cannot re-open it. Register a subset of the routes with the `relationshipsOnly` option. See [Selecting routes](./reference.md#selecting-routes). + +--- + +Next: [Polymorphism](./polymorphism.md) · [Links](./links.md) · [Errors and negotiation](./errors.md) · [Reference](./reference.md) diff --git a/docs/writing-data.md b/docs/writing-data.md deleted file mode 100644 index 2a84083..0000000 --- a/docs/writing-data.md +++ /dev/null @@ -1,99 +0,0 @@ -# Writing data - -Creating, updating and deleting resources from JSON:API request documents, and the relationship endpoints. - -## Resource writes - -JSON:API write requests wrap everything in a resource document: - -```json -POST /api/v1/articles -Content-Type: application/vnd.api+json - -{ - "data": { - "type": "articles", - "attributes": { "title": "Hello", "body": "..." }, - "relationships": { - "author": { "data": { "type": "users", "id": "7" } }, - "tags": { "data": [{ "type": "tags", "id": "1" }] } - } - } -} -``` - -`jsonApi.deserialize(Model)` unpacks that into Lucid-friendly shapes: - -```ts -async store({ jsonApi }: HttpContext) { - const input = await jsonApi.deserialize(Article) - // input.attributes === { title: 'Hello', body: '...', authorId: '7' } - // to-one relationships become foreign keys, ready for your validator - const payload = await createArticleValidator.validate(input.attributes) - const article = await Article.create(payload) - // input.toMany === { tags: ['1'] }, synced after save - await jsonApi.syncToMany(article, input.toMany) - return jsonApi.render(article, { status: 201 }) // sets the Location header -} - -async update({ jsonApi, params }: HttpContext) { - const article = await Article.findOrFail(params.id) - const input = await jsonApi.deserialize(Article, { expectedId: String(article.id) }) - article.merge(await updateArticleValidator.validate(input.attributes)) - await article.save() - await jsonApi.syncToMany(article, input.toMany) - return jsonApi.render(article) -} -``` - -The deserializer enforces the spec's error semantics for you: - -| Situation | Response | -| --------------------------------------------------------------------------- | ------------- | -| Malformed document (missing `data`, bad identifiers, unknown relationship…) | `400` | -| `data.type` doesn't match the endpoint | `409` | -| `data.id` missing on update, or doesn't match the URL | `400` / `409` | -| Client sends an `id` on creation (unless `allowClientIds: true`) | `403` | -| A referenced related resource doesn't exist | `404` | - -Attribute names are mapped back from their serialized names to model property names. Unknown attributes are dropped, and your validator remains the gatekeeper. - -A relation hidden by [`exposeRelationships`](./reading-data.md#static-exposerelationships) counts as unknown here. A `relationships` member naming one is rejected with the same `400`, so hiding a relation closes the resource-body write path too. - -## Relationship endpoints - -The spec defines URLs for reading and editing a relationship itself, without touching the resources on either end. Editing linkage through these URLs sends deltas rather than snapshots, which protects concurrent editors from overwriting each other; the [links guide](./links.md) walks through a lost-update example. `jsonApiResource` registers the endpoints when you provide a `relationships` controller: - -| Route | Meaning | -| --------------------------------------- | -------------------------------------- | -| `GET /articles/1/relationships/tags` | Read the linkage (`[{ type, id }, …]`) | -| `PATCH /articles/1/relationships/tags` | Replace all members | -| `POST /articles/1/relationships/tags` | Add members (never duplicates) | -| `DELETE /articles/1/relationships/tags` | Remove the given members | -| `GET /articles/1/tags` | The related resources themselves | - -The controller is thin. Every action delegates to the context helper: - -```ts -export default class ArticleRelationshipsController { - async show({ jsonApi, params }: HttpContext) { - const article = await Article.findOrFail(params.id) - return jsonApi.renderRelationship(article, params.relation) - } - async replace({ jsonApi, params }: HttpContext) { - const article = await Article.findOrFail(params.id) - return jsonApi.updateRelationship(article, params.relation, 'replace') - } - // add → 'add', remove → 'remove', related → renderRelated(...) -} -``` - -To-one relationships accept `PATCH` only (a `405` otherwise). For `hasMany`, full replacement and removal are rejected with `403`. The spec explicitly allows a server to refuse those, and the natural write path for a hasMany is the child's own belongsTo. `manyToMany` supports everything. `hasManyThrough` relationships are derived, and all writes through them are rejected. - -All five routes respect the resource's [`exposeRelationships`](./reading-data.md#static-exposerelationships). A relation the resource does not expose returns `404` here as well, so registering this controller cannot reopen something the resource hides. - -Register a subset of these routes with the `relationshipsOnly` option; see [Selecting routes](./reference.md#selecting-routes) in the reference. - ---- - -Next: [Polymorphism](./polymorphism.md) · [Links](./links.md) · [Errors & negotiation](./errors.md) · [Reference](./reference.md) diff --git a/examples/blog/README.md b/examples/blog/README.md index f2a0dff..37e30b5 100644 --- a/examples/blog/README.md +++ b/examples/blog/README.md @@ -1,8 +1,6 @@ # Blog example -A complete AdonisJS application demonstrating [@evoactivity/jsonapi-adonis](../../README.md): articles, comments, tags and users, served as a JSON:API under both `/api/v1` and `/api/v2` (to show versioned link generation). - -Built from the official `api` starter kit; the JSON:API integration was added with `node ace configure @evoactivity/jsonapi-adonis`. +A complete AdonisJS application that exercises [@evoactivity/jsonapi-adonis](../../README.md). It has articles, comments, tags, users, and attachments, served as a JSON:API under both `/api/v1` and `/api/v2` to show versioned links. It is built from the official `api` starter kit, with the integration added by `node ace add @evoactivity/jsonapi-adonis`. ## Run it @@ -15,7 +13,7 @@ node ace migration:run node ace serve --watch ``` -Seed the demo data: two authors, three articles with spread-out publication dates, tags and comments, shaped so every filter visibly changes the result. +Seed the demo data: two authors, three articles with spread-out publication dates, three tags, and comments. The data is shaped so every declared filter clearly changes the result. ```sh node ace db:seed @@ -30,25 +28,25 @@ curl 'localhost:3333/api/v1/articles/1?include=author,comments.author,tags' # Sparse fieldsets curl 'localhost:3333/api/v1/articles/1?include=author&fields[articles]=title,author&fields[users]=fullName' -# Sorting + pagination +# Sorting and pagination curl 'localhost:3333/api/v1/articles?sort=-title&page[number]=1&page[size]=2' -# Filtering. Only filters declared on ArticleResource work; curl needs -g -# so it doesn't eat the square brackets. +# Filtering. Only filters declared on ArticleResource work. curl needs -g +# so it does not eat the square brackets. -# substring search across title and body: "Intro to JSON:API", "Testing AdonisJS apps" +# substring search across title and body curl -g 'localhost:3333/api/v1/articles?filter[search]=json' -# only Bob's articles: "Testing AdonisJS apps" (use the id from your seed run) +# one author's articles (use the id from your seed run) curl -g 'localhost:3333/api/v1/articles?filter[author]=2' -# published on/after March: "Advanced Lucid patterns", "Testing AdonisJS apps" +# published on or after March curl -g 'localhost:3333/api/v1/articles?filter[publishedAfter]=2026-02-01' -# filters compose (AND): "Advanced Lucid patterns" +# filters compose with AND curl -g 'localhost:3333/api/v1/articles?filter[author]=1&filter[search]=lucid' -# undeclared filters are rejected: 400 with source.parameter = "filter[hacky]" +# an undeclared filter is rejected: 400 with source.parameter = "filter[hacky]" curl -g 'localhost:3333/api/v1/articles?filter[hacky]=1' # The same article under v2. Every link switches to /api/v2 @@ -76,24 +74,27 @@ curl -X POST 'localhost:3333/api/v1/articles/1/relationships/tags' \ # Spec-compliant errors curl 'localhost:3333/api/v1/articles/1?include=nonsense' # 400, source.parameter -curl 'localhost:3333/api/v1/articles/9999' # 404 errors document +curl 'localhost:3333/api/v1/articles/9999' # 404 error document ``` ## Where to look | File | What it shows | | ----------------------------------------------------- | ----------------------------------------------------- | -| `config/jsonapi.ts` | Package configuration + resource registration | +| `config/jsonapi.ts` | Package configuration and resource registration | | `start/routes.ts` | `router.jsonApiResource()` under two versioned groups | | `app/controllers/articles_controller.ts` | index/show/store/update/destroy | -| `app/controllers/article_relationships_controller.ts` | Relationship endpoints | -| `app/resources/` | Customized resources (curated user attributes) | +| `app/controllers/article_relationships_controller.ts` | The relationship endpoints | +| `app/controllers/preload_scopes_controller.ts` | `withPreloadScopes` end to end | +| `app/resources/user_resource.ts` | Curated attributes plus a computed value | +| `app/resources/attachment_resource.ts` | Single-table inheritance (image and video) | | `app/exceptions/handler.ts` | JSON:API error documents for API routes | +| `commands/export_articles.ts` | Serializing outside a request | | `tests/functional/jsonapi_*.spec.ts` | The full compliance test suite | ## Generate a new resource -The package ships scaffolding commands (registered in `adonisrc.ts`): +The scaffolding commands are registered in `adonisrc.ts`: ```sh # resource class + both controllers, routes appended to start/routes.ts @@ -109,4 +110,4 @@ node ace make:jsonapi:controller review --relationships node ace test ``` -The suite covers resource objects, compound documents, sparse fieldsets, sorting, pagination, filtering, resource writes, relationship endpoints, error documents, content negotiation and versioned links. Every test runs in a rolled-back transaction, and the database is truncated before the suite starts. +The suite covers resource objects, compound documents, sparse fieldsets, sorting, pagination, filtering, resource writes, relationship endpoints, error documents, content negotiation, and versioned links. Every test runs in a rolled-back transaction, and the database is truncated before the suite starts.