diff --git a/docs/reference.md b/docs/reference.md index 210d1a0..1290b2f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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. diff --git a/providers/jsonapi_provider.ts b/providers/jsonapi_provider.ts index 86b35e0..9c96175 100644 --- a/providers/jsonapi_provider.ts +++ b/providers/jsonapi_provider.ts @@ -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' @@ -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[0] + /** * Registers the JSON:API integration: * @@ -50,7 +54,7 @@ export default class JsonApiProvider { controllers: JsonApiResourceControllers, options?: JsonApiResourceOptions ) { - registerJsonApiResource(this, type, controllers, options) + return registerJsonApiResource(this, type, controllers, options) } /** @@ -100,7 +104,7 @@ declare module '@adonisjs/core/types' { type: string, controllers: JsonApiResourceControllers, options?: JsonApiResourceOptions - ): void + ): JsonApiResourceRoutes } } diff --git a/src/index.ts b/src/index.ts index eeeed54..33cc8be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export { export { updateRelationship, fetchLinkage, type RelationshipAction } from './relationships.ts' export { registerJsonApiResource, + JsonApiResourceRoutes, type JsonApiResourceControllers, type JsonApiResourceOptions, } from './routes.ts' diff --git a/src/routes.ts b/src/routes.ts index c7644ab..1d30187 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -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 { + #resource = new Map() + #relationships = new Map() + + /** @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( + routes: Map, + actions: Action[] | '*', + middleware: unknown +) { + for (const [action, route] of routes) { + if (actions === '*' || actions.includes(action)) { + route.use(middleware as never) + } + } +} + +export function registerJsonApiResource( router: RouterLike, type: string, controllers: JsonApiResourceControllers, options: JsonApiResourceOptions = {} -): void { +): JsonApiResourceRoutes { const { resource, relationships } = controllers + const routes = new JsonApiResourceRoutes() 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 } diff --git a/tests/unit/register_resource.spec.ts b/tests/unit/register_resource.spec.ts index 37a8038..67769dc 100644 --- a/tests/unit/register_resource.spec.ts +++ b/tests/unit/register_resource.spec.ts @@ -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() + 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, @@ -121,3 +132,71 @@ test.group('registerJsonApiResource route selection', () => { ]) }) }) + +test.group('registerJsonApiResource route middleware', () => { + function middlewareOn(router: ReturnType) { + return Object.fromEntries(router.middleware) + } + + test('use attaches middleware to the listed resource routes only', ({ assert }) => { + const router = recordingRouter() + registerJsonApiResource(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(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(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(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(router, 'articles', controllers) + .use('*', 'first') + .use(['show'], 'second') + + assert.deepEqual(router.middleware.get('articles.show'), ['first', 'second']) + }) +})