From 3660cfd116136f03d3ef32ea5b1fce5e37ead8e6 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Wed, 12 Aug 2026 09:15:36 +0100 Subject: [PATCH] feat: add relationshipsOnly to select relationship routes (#13) jsonApiResource could subset resource routes via only but registered relationship routes all-or-nothing. Add a separate relationshipsOnly option listing the relationship actions to register (show, replace, add, remove, related). The two lists are independent: omit one and every route on that axis registers, so only keeps its current meaning and nothing breaks. Closes #13 --- docs/reference.md | 37 ++++++++ docs/writing-data.md | 2 + src/routes.ts | 75 +++++++++++----- tests/unit/register_resource.spec.ts | 123 +++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 20 deletions(-) create mode 100644 tests/unit/register_resource.spec.ts diff --git a/docs/reference.md b/docs/reference.md index 62e1d50..8679e74 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -65,6 +65,43 @@ Use `make:jsonapi:controller` when the auto-derived resource is all you need. It 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. +## 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. + +`only` tokens (the `resource` controller): + +| Token | Method + path | +| --------- | ---------------------- | +| `index` | `GET /articles` | +| `store` | `POST /articles` | +| `show` | `GET /articles/:id` | +| `update` | `PATCH /articles/:id` | +| `destroy` | `DELETE /articles/:id` | + +`relationshipsOnly` tokens (the `relationships` controller): + +| Token | Method + path | +| --------- | ---------------------------------------------- | +| `show` | `GET /articles/:id/relationships/:relation` | +| `replace` | `PATCH /articles/:id/relationships/:relation` | +| `add` | `POST /articles/:id/relationships/:relation` | +| `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: + +```ts +router.jsonApiResource( + 'articles', + { + resource: () => import('#controllers/articles_controller'), + relationships: () => import('#controllers/article_relationships_controller'), + }, + { relationshipsOnly: ['show', 'related'] } +) +``` + ## 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. diff --git a/docs/writing-data.md b/docs/writing-data.md index e78754a..7c70f35 100644 --- a/docs/writing-data.md +++ b/docs/writing-data.md @@ -92,6 +92,8 @@ To-one relationships accept `PATCH` only (a `405` otherwise). For `hasMany`, ful 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: [Links](./links.md) · [Errors & negotiation](./errors.md) · [Reference](./reference.md) diff --git a/src/routes.ts b/src/routes.ts index ec9566e..c7644ab 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -18,8 +18,19 @@ * version-correct URLs and to omit links for unregistered routes. */ +/** + * Resource controller actions, matching its method names. Selected with the + * `only` option. + */ export type ResourceActions = 'index' | 'show' | 'store' | 'update' | 'destroy' +/** + * Relationship controller actions, matching its method names. Selected with + * the `relationshipsOnly` option. `show`/`replace`/`add`/`remove` serve + * `/:id/relationships/:relation`; `related` serves `/:id/:relation`. + */ +export type RelationshipActions = 'show' | 'replace' | 'add' | 'remove' | 'related' + export type JsonApiResourceControllers = { /** * Controller with index/show/store/update/destroy actions (any subset, @@ -28,13 +39,23 @@ export type JsonApiResourceControllers = { resource?: LazyController /** * Controller with show/replace/add/remove/related actions serving - * `/:id/relationships/:relation` and `/:id/:relation` routes. + * `/:id/relationships/:relation` and `/:id/:relation` routes (any subset, + * limited via `relationshipsOnly`). */ relationships?: LazyController } export type JsonApiResourceOptions = { + /** + * Register only the listed resource routes. Omit to register every action + * the `resource` controller supports. + */ only?: ResourceActions[] + /** + * Register only the listed relationship routes. Omit to register every + * action the `relationships` controller supports. + */ + relationshipsOnly?: RelationshipActions[] } type ControllerConstructor = new (...args: never[]) => unknown @@ -60,31 +81,45 @@ export function registerJsonApiResource( options: JsonApiResourceOptions = {} ): void { const { resource, relationships } = controllers - const wants = (action: ResourceActions) => !options.only || options.only.includes(action) + const wantsResource = (action: ResourceActions) => !options.only || options.only.includes(action) + const wantsRelationship = (action: RelationshipActions) => + !options.relationshipsOnly || options.relationshipsOnly.includes(action) if (resource) { - if (wants('index')) router.get(type, [resource, 'index']).as(`${type}.index`) - if (wants('store')) router.post(type, [resource, 'store']).as(`${type}.store`) - if (wants('show')) router.get(`${type}/:id`, [resource, 'show']).as(`${type}.show`) - if (wants('update')) router.patch(`${type}/:id`, [resource, 'update']).as(`${type}.update`) - if (wants('destroy')) { + if (wantsResource('index')) router.get(type, [resource, 'index']).as(`${type}.index`) + if (wantsResource('store')) router.post(type, [resource, 'store']).as(`${type}.store`) + if (wantsResource('show')) router.get(`${type}/:id`, [resource, 'show']).as(`${type}.show`) + if (wantsResource('update')) { + router.patch(`${type}/:id`, [resource, 'update']).as(`${type}.update`) + } + if (wantsResource('destroy')) { router.delete(`${type}/:id`, [resource, 'destroy']).as(`${type}.destroy`) } } if (relationships) { - router - .get(`${type}/:id/relationships/:relation`, [relationships, 'show']) - .as(`${type}.relationships.show`) - router - .patch(`${type}/:id/relationships/:relation`, [relationships, 'replace']) - .as(`${type}.relationships.replace`) - router - .post(`${type}/:id/relationships/:relation`, [relationships, 'add']) - .as(`${type}.relationships.add`) - router - .delete(`${type}/:id/relationships/:relation`, [relationships, 'remove']) - .as(`${type}.relationships.remove`) - router.get(`${type}/:id/:relation`, [relationships, 'related']).as(`${type}.related`) + if (wantsRelationship('show')) { + router + .get(`${type}/:id/relationships/:relation`, [relationships, 'show']) + .as(`${type}.relationships.show`) + } + if (wantsRelationship('replace')) { + router + .patch(`${type}/:id/relationships/:relation`, [relationships, 'replace']) + .as(`${type}.relationships.replace`) + } + if (wantsRelationship('add')) { + router + .post(`${type}/:id/relationships/:relation`, [relationships, 'add']) + .as(`${type}.relationships.add`) + } + if (wantsRelationship('remove')) { + router + .delete(`${type}/:id/relationships/:relation`, [relationships, 'remove']) + .as(`${type}.relationships.remove`) + } + if (wantsRelationship('related')) { + router.get(`${type}/:id/:relation`, [relationships, 'related']).as(`${type}.related`) + } } } diff --git a/tests/unit/register_resource.spec.ts b/tests/unit/register_resource.spec.ts new file mode 100644 index 0000000..37a8038 --- /dev/null +++ b/tests/unit/register_resource.spec.ts @@ -0,0 +1,123 @@ +/** + * registerJsonApiResource turns a type plus one or two controllers into a set + * of named routes. These tests drive it with a fake router that records the + * route names, so they assert exactly which routes register for a given + * selection without booting AdonisJS. + * + * `only` selects resource routes and `relationshipsOnly` selects relationship + * routes. The two are independent: omit one and every route that controller + * supports registers; pass it and only the listed actions register. + */ +import { test } from '@japa/runner' +import { registerJsonApiResource } from '../../src/routes.ts' +import type { JsonApiResourceOptions } from '../../src/routes.ts' + +/** + * A stand-in for the AdonisJS router that records the name of every route + * registered through it. Only the four verbs registerJsonApiResource uses + * are implemented, each returning the `.as()` recorder. + */ +function recordingRouter() { + const names: string[] = [] + const record = () => ({ + as(name: string) { + names.push(name) + return {} + }, + }) + return { + names, + get: record, + post: record, + patch: record, + delete: record, + } +} + +const lazyController = () => Promise.resolve({ default: class {} }) + +const controllers = { + resource: lazyController, + relationships: lazyController, +} + +function registeredNames(options?: JsonApiResourceOptions): string[] { + const router = recordingRouter() + registerJsonApiResource(router, 'articles', controllers, options) + return router.names +} + +test.group('registerJsonApiResource route selection', () => { + test('registers every resource and relationship route by default', ({ assert }) => { + assert.deepEqual(registeredNames(), [ + 'articles.index', + 'articles.store', + 'articles.show', + 'articles.update', + 'articles.destroy', + 'articles.relationships.show', + 'articles.relationships.replace', + 'articles.relationships.add', + 'articles.relationships.remove', + 'articles.related', + ]) + }) + + test('only selects resource routes and leaves relationship routes untouched', ({ assert }) => { + assert.deepEqual(registeredNames({ only: ['index'] }), [ + 'articles.index', + 'articles.relationships.show', + 'articles.relationships.replace', + 'articles.relationships.add', + 'articles.relationships.remove', + 'articles.related', + ]) + }) + + test('relationshipsOnly selects relationship routes and leaves resource routes untouched', ({ + assert, + }) => { + assert.deepEqual(registeredNames({ relationshipsOnly: ['show', 'related'] }), [ + 'articles.index', + 'articles.store', + 'articles.show', + 'articles.update', + 'articles.destroy', + 'articles.relationships.show', + 'articles.related', + ]) + }) + + test('only and relationshipsOnly narrow both axes at once', ({ assert }) => { + assert.deepEqual(registeredNames({ only: ['index'], relationshipsOnly: ['show'] }), [ + 'articles.index', + 'articles.relationships.show', + ]) + }) + + test('registers no relationship routes when no relationships controller is given', ({ + assert, + }) => { + const router = recordingRouter() + registerJsonApiResource(router, 'articles', { resource: lazyController }) + assert.deepEqual(router.names, [ + 'articles.index', + 'articles.store', + 'articles.show', + 'articles.update', + 'articles.destroy', + ]) + }) + + test('registers no resource routes when no resource controller is given', ({ assert }) => { + const router = recordingRouter() + registerJsonApiResource(router, 'articles', { relationships: lazyController }) + assert.deepEqual(router.names, [ + 'articles.relationships.show', + 'articles.relationships.replace', + 'articles.relationships.add', + 'articles.relationships.remove', + 'articles.related', + ]) + }) +})