From 143db707aa561dd99d88b1932fd550998e676ad0 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Mon, 17 Aug 2026 15:38:13 +0100 Subject: [PATCH] Pass the request context to filter handlers Handlers already received { Model, name }; ctx joins it, carrying the request when filtering runs inside one and staying undefined on the low-level path. A filter can now depend on the viewer, the current user's favourites being the motivating case downstream, without splitting its constraint between the resource declaration and the controller. filter.custom types the parameter; existing handlers are unaffected. --- .prettierignore | 2 ++ docs/reading-data.md | 7 +++++++ src/context.ts | 8 +++++++- src/filters.ts | 30 +++++++++++++++++++++--------- tests/unit/filters.spec.ts | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/.prettierignore b/.prettierignore index 44ed83f..c688fdc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,5 @@ # changelog, so neither should be hand-formatted. pnpm-lock.yaml CHANGELOG.md +# Regenerated unformatted by the example app's migrations +examples/blog/database/schema.ts diff --git a/docs/reading-data.md b/docs/reading-data.md index 0829b1c..50d0f68 100644 --- a/docs/reading-data.md +++ b/docs/reading-data.md @@ -205,6 +205,13 @@ export default class ArticleResource extends JsonApiResource
{ search: filter.custom((query, value) => { query.where((q) => q.whereILike('title', `%${value}%`).orWhereILike('body', `%${value}%`)) }), + + // Handlers also receive { Model, name, ctx }. ctx is the request + // when filtering runs inside one, so a filter can depend on the + // viewer; it is undefined on the low-level path outside a request. + mine: filter.custom((query, _value, { ctx }) => { + query.where('author_id', ctx!.auth.user!.id) + }), } } ``` diff --git a/src/context.ts b/src/context.ts index 6b4d6fb..c502f9d 100644 --- a/src/context.ts +++ b/src/context.ts @@ -134,7 +134,13 @@ export class JsonApiRequestContext { const preloadScopes = preloadScopesFor(query) applyIncludes(dynamicQuery, this.params.include, model, preloadScopes) applySort(dynamicQuery, model, this.params.sort) - applyFilters(dynamicQuery, model, this.#registry.resourceFor(model), this.params.filter) + applyFilters( + dynamicQuery, + model, + this.#registry.resourceFor(model), + this.params.filter, + this.#ctx + ) return query } diff --git a/src/filters.ts b/src/filters.ts index 83fd4f4..0ddc958 100644 --- a/src/filters.ts +++ b/src/filters.ts @@ -1,3 +1,4 @@ +import type { HttpContext } from '@adonisjs/core/http' import type { LucidModel, ModelQueryBuilderContract } from '@adonisjs/lucid/types/model' import { JsonApiException } from './errors.ts' import { csvList } from './params.ts' @@ -10,16 +11,24 @@ import { resolveColumn } from './query.ts' */ export type FilterQuery = ModelQueryBuilderContract +/** + * What a filter handler knows about where it is being applied. ctx is + * the request when filtering runs inside one, so a handler can depend + * on the viewer (the current user's favourites, say), and undefined on + * the low-level path outside any request. + */ +export type FilterContext = { + Model: LucidModel + name: string + ctx?: HttpContext +} + /** * A declared filter: receives the model query builder, the raw value from * `?filter[name]=...` (a string, or an array when the client sent commas), * and metadata about where it is being applied. */ -export type FilterHandler = ( - query: FilterQuery, - value: unknown, - context: { Model: LucidModel; name: string } -) => void +export type FilterHandler = (query: FilterQuery, value: unknown, context: FilterContext) => void /** * Nothing is filterable unless the resource declares it: @@ -111,8 +120,10 @@ export const filter = { * Full control: receive the Lucid query builder and the raw value. * Scopes, joins and subqueries all work here. */ - custom(handler: (query: FilterQuery, value: unknown) => void): FilterHandler { - return (query, value) => handler(query, value) + custom( + handler: (query: FilterQuery, value: unknown, context: FilterContext) => void + ): FilterHandler { + return handler }, } @@ -147,7 +158,8 @@ export function applyFilters( query: FilterQuery, Model: LucidModel, ResourceClass: { filters?: Record }, - filters: Record + filters: Record, + ctx?: HttpContext ): void { for (const [name, value] of Object.entries(filters)) { const handler = ResourceClass.filters?.[name] @@ -157,6 +169,6 @@ export function applyFilters( `"${name}" is not a supported filter for ${Model.name}` ) } - handler(query, value, { Model, name }) + handler(query, value, { Model, name, ctx }) } } diff --git a/tests/unit/filters.spec.ts b/tests/unit/filters.spec.ts index c829a7e..55122f1 100644 --- a/tests/unit/filters.spec.ts +++ b/tests/unit/filters.spec.ts @@ -1,6 +1,8 @@ import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' import { filter, applyFilters } from '../../src/filters.ts' import { JsonApiException } from '../../src/errors.ts' +import { JsonApiResource } from '../../src/resource.ts' import { Article } from '../fixtures/models.ts' /** @@ -126,3 +128,36 @@ test.group('applyFilters policy', () => { assert.deepEqual(apply({}), []) }) }) + +test.group('filter handlers receive the request context', () => { + function capturingResource() { + const captured: { ctx?: unknown } = {} + class FilteredResource extends JsonApiResource
{ + static model = () => Article + static filters = { + mine: filter.custom((_query, _value, context) => { + captured.ctx = context.ctx + }), + } + } + return { FilteredResource, captured } + } + + test('a custom filter can read the current request', ({ assert }) => { + const { FilteredResource, captured } = capturingResource() + const httpContext = new HttpContextFactory().create() + + // the query object is opaque to the handler under test + applyFilters({} as never, Article, FilteredResource, { mine: '1' }, httpContext) + + assert.strictEqual(captured.ctx, httpContext) + }) + + test('the low-level path outside a request leaves ctx undefined', ({ assert }) => { + const { FilteredResource, captured } = capturingResource() + + applyFilters({} as never, Article, FilteredResource, { mine: '1' }) + + assert.isUndefined(captured.ctx) + }) +})