From e623ea07d14eaa017aa2eb0c01c8e342b089dda7 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Tue, 13 Oct 2026 16:05:50 +0100 Subject: [PATCH] Return chainable resource routes from jsonApiResource router.jsonApiResource() now returns the routes it registered, so middleware can be attached to some actions only. use() covers resource actions and useRelationships() covers relationship actions, each taking a list of action names or '*', modelled on AdonisJS RouteResource.use(). Middleware types come from the AdonisJS route's own use(), and actions that were not registered are ignored. Existing callers are unaffected. --- docs/reference.md | 22 +++++ providers/jsonapi_provider.ts | 10 +- src/index.ts | 1 + src/routes.ts | 143 ++++++++++++++++++++++----- tests/unit/register_resource.spec.ts | 95 ++++++++++++++++-- 5 files changed, 236 insertions(+), 35 deletions(-) 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']) + }) +})