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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions docs/writing-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
75 changes: 55 additions & 20 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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`)
}
}
}
123 changes: 123 additions & 0 deletions tests/unit/register_resource.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
])
})
})
Loading