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
22 changes: 22 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,28 @@ router.jsonApiResource(
)
```

## Route middleware

`router.jsonApiResource()` returns the routes it registered. Use them to attach middleware to some actions only. The returned object works like the AdonisJS `RouteResource.use()`:

- `use(actions, middleware)` attaches middleware to resource routes. The tokens are the same as for `only`.
- `useRelationships(actions, middleware)` attaches middleware to relationship routes. The tokens are the same as for `relationshipsOnly`.
- Give `'*'` instead of a list to attach to every route on that axis.

Each call returns the same object, so you can chain calls. A token for a route that did not register (because `only` or `relationshipsOnly` left it out) does nothing.

```ts
router
.jsonApiResource('articles', {
resource: () => import('#controllers/articles_controller'),
relationships: () => import('#controllers/article_relationships_controller'),
})
.use(['store', 'update', 'destroy'], middleware.auth())
.useRelationships(['replace', 'add', 'remove'], middleware.auth())
```

This is route middleware. It runs after the middleware of the groups that contain the resource.

## Roadmap

- **[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.
Expand Down
10 changes: 7 additions & 3 deletions providers/jsonapi_provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ApplicationService } from '@adonisjs/core/types'
import { HttpContext } from '@adonisjs/core/http'
import { HttpContext, type Route } from '@adonisjs/core/http'
import { ModelQueryBuilder } from '@adonisjs/lucid/orm'
import { JsonApiRegistry } from '../src/registry.ts'
import { JsonApiRequestContext } from '../src/context.ts'
Expand All @@ -9,8 +9,12 @@ import {
registerJsonApiResource,
type JsonApiResourceControllers,
type JsonApiResourceOptions,
type JsonApiResourceRoutes,
} from '../src/routes.ts'

/** Whatever an AdonisJS route's own use() accepts. */
type RouteMiddleware = Parameters<Route['use']>[0]

/**
* Registers the JSON:API integration:
*
Expand Down Expand Up @@ -50,7 +54,7 @@ export default class JsonApiProvider {
controllers: JsonApiResourceControllers,
options?: JsonApiResourceOptions
) {
registerJsonApiResource(this, type, controllers, options)
return registerJsonApiResource<RouteMiddleware>(this, type, controllers, options)
}

/**
Expand Down Expand Up @@ -100,7 +104,7 @@ declare module '@adonisjs/core/types' {
type: string,
controllers: JsonApiResourceControllers,
options?: JsonApiResourceOptions
): void
): JsonApiResourceRoutes<RouteMiddleware>
}
}

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
export { updateRelationship, fetchLinkage, type RelationshipAction } from './relationships.ts'
export {
registerJsonApiResource,
JsonApiResourceRoutes,
type JsonApiResourceControllers,
type JsonApiResourceOptions,
} from './routes.ts'
143 changes: 119 additions & 24 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,64 +62,159 @@ type ControllerConstructor = new (...args: never[]) => unknown

type LazyController = () => Promise<{ default: ControllerConstructor }>

/**
* Structural slice of an AdonisJS route: naming it and attaching
* middleware, which is all the helper and the returned routes need.
*/
type RouteLike = {
as(name: string): RouteLike
use(middleware: never): unknown
}

/**
* Structural slice of the AdonisJS router used by the helper. The handler
* is typed loosely (unknown) because the real router accepts a wide union;
* this helper only ever passes [LazyController, actionName] tuples.
*/
type RouterLike = {
get(pattern: string, handler: unknown): { as(name: string): unknown }
post(pattern: string, handler: unknown): { as(name: string): unknown }
patch(pattern: string, handler: unknown): { as(name: string): unknown }
delete(pattern: string, handler: unknown): { as(name: string): unknown }
get(pattern: string, handler: unknown): RouteLike
post(pattern: string, handler: unknown): RouteLike
patch(pattern: string, handler: unknown): RouteLike
delete(pattern: string, handler: unknown): RouteLike
}

export function registerJsonApiResource(
/**
* The routes one `jsonApiResource()` call registered, kept by action so
* middleware can be attached to some of them. Modelled on AdonisJS's own
* `RouteResource.use()`. Each call returns the same object, so calls
* chain. Naming an action that was not registered (left out with `only`
* or `relationshipsOnly`) does nothing.
*
* Middleware attached here is route middleware, so it runs after the
* middleware of the groups the resource sits in.
*
* ```ts
* router
* .jsonApiResource('articles', { resource, relationships })
* .use(['store', 'update', 'destroy'], middleware.auth())
* .useRelationships(['replace', 'add', 'remove'], middleware.auth())
* ```
*/
export class JsonApiResourceRoutes<Middleware = never> {
#resource = new Map<ResourceActions, RouteLike>()
#relationships = new Map<RelationshipActions, RouteLike>()

/** @internal Called by registerJsonApiResource as it registers each route. */
addResource(action: ResourceActions, route: RouteLike) {
this.#resource.set(action, route)
}

/** @internal Called by registerJsonApiResource as it registers each route. */
addRelationship(action: RelationshipActions, route: RouteLike) {
this.#relationships.set(action, route)
}

/** Attaches middleware to the listed resource routes, or all of them with '*'. */
use(actions: ResourceActions[] | '*', middleware: Middleware): this {
attach(this.#resource, actions, middleware)
return this
}

/** Attaches middleware to the listed relationship routes, or all of them with '*'. */
useRelationships(actions: RelationshipActions[] | '*', middleware: Middleware): this {
attach(this.#relationships, actions, middleware)
return this
}
}

function attach<Action extends string>(
routes: Map<Action, RouteLike>,
actions: Action[] | '*',
middleware: unknown
) {
for (const [action, route] of routes) {
if (actions === '*' || actions.includes(action)) {
route.use(middleware as never)
}
}
}

export function registerJsonApiResource<Middleware = never>(
router: RouterLike,
type: string,
controllers: JsonApiResourceControllers,
options: JsonApiResourceOptions = {}
): void {
): JsonApiResourceRoutes<Middleware> {
const { resource, relationships } = controllers
const routes = new JsonApiResourceRoutes<Middleware>()
const wantsResource = (action: ResourceActions) => !options.only || options.only.includes(action)
const wantsRelationship = (action: RelationshipActions) =>
!options.relationshipsOnly || options.relationshipsOnly.includes(action)

if (resource) {
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('index')) {
routes.addResource('index', router.get(type, [resource, 'index']).as(`${type}.index`))
}
if (wantsResource('store')) {
routes.addResource('store', router.post(type, [resource, 'store']).as(`${type}.store`))
}
if (wantsResource('show')) {
routes.addResource('show', router.get(`${type}/:id`, [resource, 'show']).as(`${type}.show`))
}
if (wantsResource('update')) {
router.patch(`${type}/:id`, [resource, 'update']).as(`${type}.update`)
routes.addResource(
'update',
router.patch(`${type}/:id`, [resource, 'update']).as(`${type}.update`)
)
}
if (wantsResource('destroy')) {
router.delete(`${type}/:id`, [resource, 'destroy']).as(`${type}.destroy`)
routes.addResource(
'destroy',
router.delete(`${type}/:id`, [resource, 'destroy']).as(`${type}.destroy`)
)
}
}

if (relationships) {
if (wantsRelationship('show')) {
router
.get(`${type}/:id/relationships/:relation`, [relationships, 'show'])
.as(`${type}.relationships.show`)
routes.addRelationship(
'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`)
routes.addRelationship(
'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`)
routes.addRelationship(
'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`)
routes.addRelationship(
'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`)
routes.addRelationship(
'related',
router.get(`${type}/:id/:relation`, [relationships, 'related']).as(`${type}.related`)
)
}
}

return routes
}
95 changes: 87 additions & 8 deletions tests/unit/register_resource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,30 @@ 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.
* registered through it, and the middleware attached to each route by
* name. Only the four verbs registerJsonApiResource uses are implemented.
*/
function recordingRouter() {
const names: string[] = []
const record = () => ({
as(name: string) {
names.push(name)
return {}
},
})
const middleware = new Map<string, string[]>()
const record = () => {
let routeName = ''
const route = {
as(name: string) {
routeName = name
names.push(name)
return route
},
use(entry: string) {
middleware.set(routeName, [...(middleware.get(routeName) ?? []), entry])
return route
},
}
return route
}
return {
names,
middleware,
get: record,
post: record,
patch: record,
Expand Down Expand Up @@ -121,3 +132,71 @@ test.group('registerJsonApiResource route selection', () => {
])
})
})

test.group('registerJsonApiResource route middleware', () => {
function middlewareOn(router: ReturnType<typeof recordingRouter>) {
return Object.fromEntries(router.middleware)
}

test('use attaches middleware to the listed resource routes only', ({ assert }) => {
const router = recordingRouter()
registerJsonApiResource<string>(router, 'articles', controllers)
.use(['show'], 'read')
.use(['store', 'update'], 'write')

assert.deepEqual(middlewareOn(router), {
'articles.show': ['read'],
'articles.store': ['write'],
'articles.update': ['write'],
})
})

test('use with * reaches every resource route and no relationship route', ({ assert }) => {
const router = recordingRouter()
registerJsonApiResource<string>(router, 'articles', controllers).use('*', 'auth')

assert.deepEqual(Object.keys(middlewareOn(router)), [
'articles.index',
'articles.store',
'articles.show',
'articles.update',
'articles.destroy',
])
})

test('useRelationships attaches to relationship routes, keeping show apart from the resource show', ({
assert,
}) => {
const router = recordingRouter()
registerJsonApiResource<string>(router, 'articles', controllers)
.use(['show'], 'resource')
.useRelationships(['show', 'replace'], 'relationship')

assert.deepEqual(middlewareOn(router), {
'articles.show': ['resource'],
'articles.relationships.show': ['relationship'],
'articles.relationships.replace': ['relationship'],
})
})

test('naming an action that was not registered does nothing', ({ assert }) => {
const router = recordingRouter()
registerJsonApiResource<string>(router, 'articles', controllers, {
only: ['index'],
relationshipsOnly: ['show'],
})
.use(['destroy'], 'never')
.useRelationships(['remove'], 'never')

assert.deepEqual(middlewareOn(router), {})
})

test('middleware attached in several calls keeps its order on a route', ({ assert }) => {
const router = recordingRouter()
registerJsonApiResource<string>(router, 'articles', controllers)
.use('*', 'first')
.use(['show'], 'second')

assert.deepEqual(router.middleware.get('articles.show'), ['first', 'second'])
})
})
Loading