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) + }) +})