From cb067cb35d7147b3201a36024f5df5014a198829 Mon Sep 17 00:00:00 2001 From: fratzinger <22286818+fratzinger@users.noreply.github.com> Date: Tue, 12 May 2026 11:27:20 +0200 Subject: [PATCH 1/2] fix: around hook type signature Promise --- .claude/settings.json | 3 +- src/hooks/cache/cache.hook.test.ts | 15 ++- src/hooks/cache/cache.hook.ts | 16 ++- .../check-multi/check-multi.hook.test.ts | 56 +++++++- src/hooks/check-multi/check-multi.hook.ts | 7 +- .../check-required.hook.test.ts | 60 ++++++++- .../check-required/check-required.hook.ts | 7 +- .../create-related.hook.test.ts | 21 ++- .../create-related/create-related.hook.ts | 4 +- src/hooks/debug/debug.hook.test.ts | 17 +++ src/hooks/debug/debug.hook.ts | 2 +- .../disable-pagination.hook.test.ts | 121 ++++++++++++++++-- .../disable-pagination.hook.ts | 11 +- src/hooks/disallow/disallow.hook.test.ts | 60 ++++++++- src/hooks/disallow/disallow.hook.ts | 9 +- src/hooks/on-delete/on-delete.hook.test.ts | 14 ++ src/hooks/on-delete/on-delete.hook.ts | 4 +- .../params-for-server.hook.test.ts | 100 +++++++++++---- .../params-for-server.hook.ts | 17 ++- .../params-from-client.hook.test.ts | 87 +++++++++---- .../params-from-client.hook.ts | 16 ++- .../prevent-changes.hook.test.ts | 47 +++---- src/hooks/rate-limit/rate-limit.hook.test.ts | 35 ++++- src/hooks/rate-limit/rate-limit.hook.ts | 4 +- src/hooks/set-data/set-data.hook.test.ts | 102 +++++++++------ src/hooks/set-data/set-data.hook.ts | 15 ++- src/hooks/set-field/set-field.hook.test.ts | 51 +++++++- src/hooks/set-field/set-field.hook.ts | 23 ++-- src/hooks/set-result/set-result.hook.test.ts | 116 +++++++++++------ src/hooks/set-result/set-result.hook.ts | 12 +- src/hooks/set-slug/set-slug.hook.test.ts | 41 +++++- src/hooks/set-slug/set-slug.hook.ts | 14 +- src/hooks/skippable/skippable.hook.test.ts | 61 ++++++++- src/hooks/skippable/skippable.hook.ts | 27 ++-- .../soft-delete/soft-delete.hook.test.ts | 17 ++- src/hooks/soft-delete/soft-delete.hook.ts | 9 +- src/hooks/stashable/stashable.hook.test.ts | 19 ++- src/hooks/stashable/stashable.hook.ts | 17 +-- src/hooks/throw-if/throw-if.hook.test.ts | 33 ++++- src/hooks/throw-if/throw-if.hook.ts | 4 +- .../transform-data.hook.test.ts | 43 ++++++- .../transform-data/transform-data.hook.ts | 6 +- .../transform-query.hook.test.ts | 45 ++++++- .../transform-query/transform-query.hook.ts | 11 +- .../transform-result.hook.test.ts | 40 +++++- .../transform-result/transform-result.hook.ts | 15 +-- src/hooks/traverse/traverse.hook.test.ts | 41 +++++- src/hooks/traverse/traverse.hook.ts | 21 +-- 48 files changed, 1200 insertions(+), 316 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 6c1ea62..87de6da 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -20,7 +20,8 @@ "Bash(gh release list *)", "Bash(gh release view *)", "Bash(npm run *)", - "Bash(pnpm typecheck *)" + "Bash(pnpm typecheck *)", + "Bash(pnpm vitest *)" ] } } diff --git a/src/hooks/cache/cache.hook.test.ts b/src/hooks/cache/cache.hook.test.ts index ff2a7dd..40e3f93 100644 --- a/src/hooks/cache/cache.hook.test.ts +++ b/src/hooks/cache/cache.hook.test.ts @@ -1,11 +1,11 @@ -import type { HookContext } from '@feathersjs/feathers' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { feathers } from '@feathersjs/feathers' import type { CacheOptions } from './cache.hook.js' import { cache } from './cache.hook.js' import { LRUCache } from 'lru-cache' import { TTLCache } from '@isaacs/ttlcache' import { MemoryService } from '@feathersjs/memory' -import { expect } from 'vitest' +import { expect, expectTypeOf } from 'vitest' import { copy } from 'fast-copy' const setup = (options: CacheOptions, serviceOptions?: { id?: string }) => { @@ -962,4 +962,15 @@ describe('cache hook with custom serialize', () => { await usersService.find({ query: { name: 'Jane' } }) expect(before.find).toHaveBeenCalledTimes(1) }) + + it('is type-compatible with AroundHookFunction', () => { + type Item = { id: number; name: string } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + expectTypeOf( + cache({ map: new Map(), transformParams: (p) => p }), + ).toExtend>>() + }) }) diff --git a/src/hooks/cache/cache.hook.ts b/src/hooks/cache/cache.hook.ts index 9280b7e..c2fd754 100755 --- a/src/hooks/cache/cache.hook.ts +++ b/src/hooks/cache/cache.hook.ts @@ -93,7 +93,7 @@ export const cache = ( options: CacheOptions, ) => { const cacheMap = new ContextCacheMap(options) - return async (context: H, next?: NextFunction) => { + return async (context: H, next?: NextFunction): Promise => { if (context.type === 'before') { return await cacheBefore(context, cacheMap) } @@ -110,25 +110,27 @@ export const cache = ( } } -const cacheBefore = async (context: HookContext, cacheMap: ContextCacheMap) => { +const cacheBefore = async ( + context: HookContext, + cacheMap: ContextCacheMap, +): Promise => { if (context.method === 'get' || context.method === 'find') { const value = await cacheMap.get(context) if (value) { context.result = value } } - - return context } -const cacheAfter = async (context: HookContext, cacheMap: ContextCacheMap) => { +const cacheAfter = async ( + context: HookContext, + cacheMap: ContextCacheMap, +): Promise => { if (context.method === 'get' || context.method === 'find') { await cacheMap.set(context) } else { await cacheMap.clear(context) } - - return context } class ContextCacheMap { diff --git a/src/hooks/check-multi/check-multi.hook.test.ts b/src/hooks/check-multi/check-multi.hook.test.ts index 7e6b7f1..7da7e95 100644 --- a/src/hooks/check-multi/check-multi.hook.test.ts +++ b/src/hooks/check-multi/check-multi.hook.test.ts @@ -1,5 +1,7 @@ -import { vi } from 'vitest' -import type { HookContext } from '@feathersjs/feathers' +import { expectTypeOf, vi } from 'vitest' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { checkMulti } from './check-multi.hook.js' import { MethodNotAllowed } from '@feathersjs/errors' @@ -241,4 +243,54 @@ describe('checkMulti', function () { }) }) }) + + describe('integration with service.hooks({ around })', () => { + type User = { id: number; name: string } + type Services = { users: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + const setup = (multi: boolean) => { + const app = feathers() + app.use('users', new MemoryService({ multi })) + + app.service('users').hooks({ + around: { + create: [checkMulti()], + }, + }) + + return app + } + + it('blocks multi-create when multi is not allowed', async () => { + const service = setup(false).service('users') + + await expect( + service.create([{ name: 'Alice' }, { name: 'Bob' }]), + ).rejects.toThrow(/Can not create multiple entries/) + }) + + it('allows single-create when multi is not allowed', async () => { + const service = setup(false).service('users') + + const created = await service.create({ name: 'Alice' }) + assert.equal(created.name, 'Alice') + }) + + it('allows multi-create when multi is allowed', async () => { + const service = setup(true).service('users') + + const created = await service.create([{ name: 'Alice' }, { name: 'Bob' }]) + assert.lengthOf(created, 2) + }) + + it('is type-compatible with AroundHookFunction', () => { + const hook = checkMulti() + + expectTypeOf(hook).toExtend< + AroundHookFunction> + >() + }) + }) }) diff --git a/src/hooks/check-multi/check-multi.hook.ts b/src/hooks/check-multi/check-multi.hook.ts index 3a65820..cba8f91 100644 --- a/src/hooks/check-multi/check-multi.hook.ts +++ b/src/hooks/check-multi/check-multi.hook.ts @@ -31,7 +31,9 @@ export type CheckMultiOptions = { export function checkMulti( options?: CheckMultiOptions, ) { - return (context: H, next?: NextFunction) => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { const { service, method } = context if ( !service.allowsMulti || @@ -40,11 +42,12 @@ export function checkMulti( service.allowsMulti(method) ) { if (next) return next() - return context + return } throw options?.error ? options.error(context) : new MethodNotAllowed(`Can not ${method} multiple entries`) } + return hook } diff --git a/src/hooks/check-required/check-required.hook.test.ts b/src/hooks/check-required/check-required.hook.test.ts index 91dc2e4..3911a1c 100755 --- a/src/hooks/check-required/check-required.hook.test.ts +++ b/src/hooks/check-required/check-required.hook.test.ts @@ -1,6 +1,8 @@ -import { assert } from 'vitest' +import { assert, expectTypeOf } from 'vitest' import { checkRequired } from './check-required.hook.js' -import type { HookContext } from '@feathersjs/feathers' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' let hookBefore: HookContext @@ -38,4 +40,58 @@ describe('checkRequired', () => { it('ignores bad or missing no dot path', () => { assert.throws(() => checkRequired('xx')(hookBefore)) }) + + describe('integration with service.hooks({ around })', () => { + type User = { id: number; email: string; password: string } + type Services = { users: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + const setup = () => { + const app = feathers() + app.use('users', new MemoryService()) + + app.service('users').hooks({ + around: { + create: [checkRequired(['email', 'password'])], + }, + }) + + return app + } + + it('blocks create when a required field is missing', async () => { + const service = setup().service('users') + + await expect( + service.create({ email: 'a@b.com' } as User), + ).rejects.toThrow(/Field password does not exist/) + }) + + it('blocks create when a required field is null', async () => { + const service = setup().service('users') + + await expect( + service.create({ email: 'a@b.com', password: '' } as User), + ).rejects.toThrow(/Field password is null/) + }) + + it('allows create when all required fields are present', async () => { + const service = setup().service('users') + + const created = await service.create({ + email: 'a@b.com', + password: 'secret', + } as User) + assert.equal(created.email, 'a@b.com') + }) + + it('is type-compatible with AroundHookFunction', () => { + const hook = checkRequired(['email']) + + expectTypeOf(hook).toExtend< + AroundHookFunction> + >() + }) + }) }) diff --git a/src/hooks/check-required/check-required.hook.ts b/src/hooks/check-required/check-required.hook.ts index 94d44b5..6285596 100755 --- a/src/hooks/check-required/check-required.hook.ts +++ b/src/hooks/check-required/check-required.hook.ts @@ -27,7 +27,9 @@ export function checkRequired( fieldNames: MaybeArray, ) { const fieldNamesArray = toArray(fieldNames) - return (context: H, next?: NextFunction) => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { checkContext(context, { type: ['before', 'around'], method: ['create', 'update', 'patch'], @@ -55,5 +57,8 @@ export function checkRequired( } if (next) return next() + + return } + return hook } diff --git a/src/hooks/create-related/create-related.hook.test.ts b/src/hooks/create-related/create-related.hook.test.ts index b5ad656..534d43e 100644 --- a/src/hooks/create-related/create-related.hook.test.ts +++ b/src/hooks/create-related/create-related.hook.test.ts @@ -1,6 +1,7 @@ -import { expect } from 'vitest' +import { expect, expectTypeOf } from 'vitest' import { feathers } from '@feathersjs/feathers' import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { createRelated } from './create-related.hook.js' type MockAppOptions = { @@ -402,4 +403,22 @@ describe('hook - createRelated', function () { expect(todos).toStrictEqual([]) }) + + it('is type-compatible with AroundHookFunction', () => { + type User = { id: number; name: string } + type Todo = { id: number; userId: number; title: string } + type Services = { + users: MemoryService + todos: MemoryService + } + type App = ReturnType> + type Ctx = HookContext> + + expectTypeOf( + createRelated({ + service: 'todos', + data: (user) => ({ userId: user.id, title: 'welcome' }), + }), + ).toExtend>>() + }) }) diff --git a/src/hooks/create-related/create-related.hook.ts b/src/hooks/create-related/create-related.hook.ts index 6bf06a8..9d64389 100644 --- a/src/hooks/create-related/create-related.hook.ts +++ b/src/hooks/create-related/create-related.hook.ts @@ -54,7 +54,7 @@ export interface CreateRelatedOptions< export function createRelated( options: MaybeArray>, ) { - return async (context: H, next?: NextFunction) => { + return async (context: H, next?: NextFunction): Promise => { checkContext(context, { type: ['after', 'around'], method: ['create'], @@ -80,7 +80,7 @@ export function createRelated( .filter((x) => !!x) if (!dataToCreate || dataToCreate.length <= 0) { - return context + return } if (multi || dataToCreate.length === 1) { diff --git a/src/hooks/debug/debug.hook.test.ts b/src/hooks/debug/debug.hook.test.ts index 95a68e4..cf8e188 100755 --- a/src/hooks/debug/debug.hook.test.ts +++ b/src/hooks/debug/debug.hook.test.ts @@ -1,3 +1,7 @@ +import { expectTypeOf } from 'vitest' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { debug } from './debug.hook.js' describe('services debug', () => { @@ -22,4 +26,17 @@ describe('services debug', () => { } debug('my message', 'query', 'foo')(hook) }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + expectTypeOf(debug('msg')).toExtend< + AroundHookFunction> + >() + }) + }) }) diff --git a/src/hooks/debug/debug.hook.ts b/src/hooks/debug/debug.hook.ts index 29623b8..b0ec749 100755 --- a/src/hooks/debug/debug.hook.ts +++ b/src/hooks/debug/debug.hook.ts @@ -18,7 +18,7 @@ import type { HookContext, NextFunction } from '@feathersjs/feathers' */ export const debug = (msg: string, ...fieldNames: string[]) => - async (context: H, next?: NextFunction) => { + async (context: H, next?: NextFunction): Promise => { if (next) { await next() } diff --git a/src/hooks/disable-pagination/disable-pagination.hook.test.ts b/src/hooks/disable-pagination/disable-pagination.hook.test.ts index 5b2a929..0af9e17 100755 --- a/src/hooks/disable-pagination/disable-pagination.hook.test.ts +++ b/src/hooks/disable-pagination/disable-pagination.hook.test.ts @@ -1,42 +1,52 @@ -import { assert } from 'vitest' +import { assert, expectTypeOf } from 'vitest' import { disablePagination } from './disable-pagination.hook.js' -import type { HookContext } from '@feathersjs/feathers' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { + AroundHookFunction, + HookContext, + Paginated, +} from '@feathersjs/feathers' describe('hook - disablePagination', () => { it('disables on $limit = -1', () => { - const result: any = disablePagination()({ + const context = { type: 'before', method: 'find', params: { query: { id: 1, $limit: -1 } }, - } as HookContext) - assert.deepEqual(result.params, { paginate: false, query: { id: 1 } }) + } as HookContext + disablePagination()(context) + assert.deepEqual(context.params, { paginate: false, query: { id: 1 } }) }) it('disables on $limit = "-1"', () => { - const result: any = disablePagination()({ + const context = { type: 'before', method: 'find', params: { query: { id: 1, $limit: '-1' } }, - } as HookContext) - assert.deepEqual(result.params, { paginate: false, query: { id: 1 } }) + } as HookContext + disablePagination()(context) + assert.deepEqual(context.params, { paginate: false, query: { id: 1 } }) }) it('disables on $limit = -1 in around', () => { - const result: any = disablePagination()({ + const context = { type: 'around', method: 'find', params: { query: { id: 1, $limit: -1 } }, - } as HookContext) - assert.deepEqual(result.params, { paginate: false, query: { id: 1 } }) + } as HookContext + disablePagination()(context) + assert.deepEqual(context.params, { paginate: false, query: { id: 1 } }) }) it('disables on $limit = "-1" in around', () => { - const result: any = disablePagination()({ + const context = { type: 'around', method: 'find', params: { query: { id: 1, $limit: '-1' } }, - } as HookContext) - assert.deepEqual(result.params, { paginate: false, query: { id: 1 } }) + } as HookContext + disablePagination()(context) + assert.deepEqual(context.params, { paginate: false, query: { id: 1 } }) }) it('throws if after hook', () => { @@ -58,4 +68,87 @@ describe('hook - disablePagination', () => { } as HookContext) }) }) + + describe('integration with service.hooks({ around })', () => { + type User = { id: number; name: string } + type Services = { users: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + const setup = () => { + const app = feathers() + app.use( + 'users', + new MemoryService({ + paginate: { default: 10, max: 50 }, + multi: true, + }), + ) + + // The hook is wired in via the `around` map — this line is the actual + // integration assertion. If `disablePagination`'s signature is not + // assignable to `AroundHookFunction`, this call will fail type-check. + app.service('users').hooks({ + around: { + find: [disablePagination()], + }, + }) + + return app + } + + it('returns an unpaginated array when query.$limit is -1', async () => { + const service = setup().service('users') + + await service.create([ + { name: 'Alice' }, + { name: 'Bob' }, + { name: 'Carol' }, + ]) + + // Cast: the find() overload sees `$limit: number` (not the literal + // `paginate: false`), so the static return is `Paginated`. The + // hook flips `paginate: false` at runtime, which TS can't track. + const result = (await service.find({ + query: { $limit: -1 }, + })) as unknown as User[] + + assert.isArray(result) + assert.lengthOf(result, 3) + }) + + it('returns an unpaginated array when query.$limit is "-1"', async () => { + const service = setup().service('users') + + await service.create([{ name: 'Alice' }, { name: 'Bob' }]) + + const result = (await service.find({ + query: { $limit: '-1' as unknown as number }, + })) as unknown as User[] + + assert.isArray(result) + assert.lengthOf(result, 2) + }) + + it('still paginates when query.$limit is omitted', async () => { + const service = setup().service('users') + + await service.create([{ name: 'Alice' }, { name: 'Bob' }]) + + const result = await service.find() + + expectTypeOf(result).toEqualTypeOf>() + assert.isFalse(Array.isArray(result)) + assert.equal(result.total, 2) + assert.lengthOf(result.data, 2) + }) + + it('is type-compatible with AroundHookFunction', () => { + const hook = disablePagination() + + expectTypeOf(hook).toExtend< + AroundHookFunction> + >() + }) + }) }) diff --git a/src/hooks/disable-pagination/disable-pagination.hook.ts b/src/hooks/disable-pagination/disable-pagination.hook.ts index 0b0fdcc..03eed47 100755 --- a/src/hooks/disable-pagination/disable-pagination.hook.ts +++ b/src/hooks/disable-pagination/disable-pagination.hook.ts @@ -18,9 +18,10 @@ import { checkContext } from '../../utils/index.js' * * @see https://utils.feathersjs.com/hooks/disable-pagination.html */ -export const disablePagination = - () => - (context: H, next?: NextFunction) => { +export const disablePagination = () => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { checkContext(context, { type: ['before', 'around'], method: ['find'], @@ -35,5 +36,7 @@ export const disablePagination = if (next) return next() - return context + return } + return hook +} diff --git a/src/hooks/disallow/disallow.hook.test.ts b/src/hooks/disallow/disallow.hook.test.ts index 93c0487..e49bd2e 100755 --- a/src/hooks/disallow/disallow.hook.test.ts +++ b/src/hooks/disallow/disallow.hook.test.ts @@ -1,5 +1,8 @@ -import { assert } from 'vitest' +import { assert, expectTypeOf } from 'vitest' import { disallow } from './disallow.hook.js' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' describe('hook - disallow', () => { describe('disallow is compatible with .disable (without predicate)', () => { @@ -155,4 +158,59 @@ describe('hook - disallow', () => { assert.equal(result, undefined) }) }) + + describe('integration with service.hooks({ around })', () => { + type User = { id: number; name: string } + type Services = { users: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + const setup = () => { + const app = feathers() + app.use('users', new MemoryService({ multi: true })) + + // Wiring the hook into the `around` map exercises type compatibility + // with feathers' strict `AroundHookFunction`. + app.service('users').hooks({ + around: { + create: [disallow('external')], + remove: [disallow()], + }, + }) + + return app + } + + it('blocks "external" provider on create', async () => { + const service = setup().service('users') + + await expect( + service.create({ name: 'Alice' }, { provider: 'rest' }), + ).rejects.toThrow(/Provider 'rest' can not call 'create'/) + }) + + it('allows internal (no-provider) create', async () => { + const service = setup().service('users') + + const created = await service.create({ name: 'Alice' }) + assert.equal(created.name, 'Alice') + }) + + it('blocks remove for every caller', async () => { + const service = setup().service('users') + const created = await service.create({ name: 'Alice' }) + + await expect(service.remove(created.id)).rejects.toThrow( + /Method not allowed/, + ) + }) + + it('is type-compatible with AroundHookFunction', () => { + const hook = disallow('external') + + expectTypeOf(hook).toExtend< + AroundHookFunction> + >() + }) + }) }) diff --git a/src/hooks/disallow/disallow.hook.ts b/src/hooks/disallow/disallow.hook.ts index 2ff9075..098ea1d 100755 --- a/src/hooks/disallow/disallow.hook.ts +++ b/src/hooks/disallow/disallow.hook.ts @@ -28,7 +28,9 @@ export const disallow = ( transports?: MaybeArray, ) => { const transportsArr = toArray(transports) - return (context: H, next?: NextFunction) => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { if (!transports) { throw new MethodNotAllowed('Method not allowed') } @@ -39,6 +41,9 @@ export const disallow = ( ) } - if (next) return next().then(() => context) + if (next) return next() + + return } + return hook } diff --git a/src/hooks/on-delete/on-delete.hook.test.ts b/src/hooks/on-delete/on-delete.hook.test.ts index e703b0d..3a0f179 100644 --- a/src/hooks/on-delete/on-delete.hook.test.ts +++ b/src/hooks/on-delete/on-delete.hook.test.ts @@ -2,6 +2,7 @@ import { expect, expectTypeOf } from 'vitest' import { feathers } from '@feathersjs/feathers' import type { Application, + AroundHookFunction, HookContext, Params, Query, @@ -914,5 +915,18 @@ describe('onDelete', function () { expectTypeOf(hook).toBeFunction() }) + + it('is type-compatible with AroundHookFunction', function () { + type AppHookContext = HookContext + + const hook = onDelete({ + service: 'todos', + keyThere: 'userId', + keyHere: 'id', + onDelete: 'cascade', + }) + + expectTypeOf(hook).toExtend>() + }) }) }) diff --git a/src/hooks/on-delete/on-delete.hook.ts b/src/hooks/on-delete/on-delete.hook.ts index 9eefe2e..7600efc 100644 --- a/src/hooks/on-delete/on-delete.hook.ts +++ b/src/hooks/on-delete/on-delete.hook.ts @@ -72,7 +72,7 @@ export const onDelete = ( ) => { const optionsMulti = Array.isArray(options) ? options : [options] - return async (context: H, next?: NextFunction) => { + return async (context: H, next?: NextFunction): Promise => { checkContext(context, { type: ['after', 'around'], method: 'remove', @@ -97,7 +97,7 @@ export const onDelete = ( ids = [...new Set(ids)] if (!ids || ids.length <= 0) { - return context + return } const params = { diff --git a/src/hooks/params-for-server/params-for-server.hook.test.ts b/src/hooks/params-for-server/params-for-server.hook.test.ts index cab96e8..1f7f5d7 100644 --- a/src/hooks/params-for-server/params-for-server.hook.test.ts +++ b/src/hooks/params-for-server/params-for-server.hook.test.ts @@ -1,17 +1,20 @@ -import type { HookContext } from '@feathersjs/feathers' +import { expectTypeOf } from 'vitest' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { paramsForServer } from './params-for-server.hook.js' describe('paramsForServer', () => { it('should move params to query._$client', () => { - expect( - paramsForServer(['a', 'b'])({ - params: { - a: 1, - b: 2, - query: {}, - }, - } as HookContext), - ).toEqual({ + const context = { + params: { + a: 1, + b: 2, + query: {}, + }, + } as HookContext + paramsForServer(['a', 'b'])(context) + expect(context).toEqual({ params: { query: { _$client: { @@ -25,15 +28,15 @@ describe('paramsForServer', () => { it('should accept a readonly array', () => { const whitelist = ['a', 'b'] as const - expect( - paramsForServer(whitelist)({ - params: { - a: 1, - b: 2, - query: {}, - }, - } as HookContext), - ).toEqual({ + const context = { + params: { + a: 1, + b: 2, + query: {}, + }, + } as HookContext + paramsForServer(whitelist)(context) + expect(context).toEqual({ params: { query: { _$client: { @@ -46,15 +49,15 @@ describe('paramsForServer', () => { }) it('should move params to query._$client and leave remaining', () => { - expect( - paramsForServer('a')({ - params: { - a: 1, - b: 2, - query: {}, - }, - } as HookContext), - ).toEqual({ + const context = { + params: { + a: 1, + b: 2, + query: {}, + }, + } as HookContext + paramsForServer('a')(context) + expect(context).toEqual({ params: { b: 2, query: { @@ -65,4 +68,45 @@ describe('paramsForServer', () => { }, }) }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + expectTypeOf(paramsForServer('user')).toExtend< + AroundHookFunction> + >() + }) + + it('packs whitelisted params into query._$client', async () => { + const app = feathers() + app.use('items', new MemoryService()) + app.service('items').hooks({ + around: { + create: [paramsForServer('user')], + }, + }) + + let seenQuery: any + app.service('items').hooks({ + around: { + create: [ + async (ctx, next) => { + seenQuery = ctx.params.query + await next() + }, + ], + }, + }) + + await app.service('items').create({ name: 'Alice' }, { + user: { id: 1 }, + } as any) + + expect(seenQuery._$client).toEqual({ user: { id: 1 } }) + }) + }) }) diff --git a/src/hooks/params-for-server/params-for-server.hook.ts b/src/hooks/params-for-server/params-for-server.hook.ts index 8eb6005..d11b61f 100644 --- a/src/hooks/params-for-server/params-for-server.hook.ts +++ b/src/hooks/params-for-server/params-for-server.hook.ts @@ -35,7 +35,15 @@ export const paramsForServer = ( const { keyToHide = FROM_CLIENT_FOR_SERVER_DEFAULT_KEY } = options || {} - return (context: H, next?: NextFunction) => { + function hook(context: H): void + function hook( + context: H, + next: NextFunction, + ): Promise + function hook( + context: H, + next?: NextFunction, + ): void | Promise { // clone params on demand let clonedParams: any @@ -67,10 +75,9 @@ export const paramsForServer = ( context.params = clonedParams } - if (next) { - return next() - } + if (next) return next() - return context + return } + return hook } diff --git a/src/hooks/params-from-client/params-from-client.hook.test.ts b/src/hooks/params-from-client/params-from-client.hook.test.ts index 65518eb..cf99b1c 100644 --- a/src/hooks/params-from-client/params-from-client.hook.test.ts +++ b/src/hooks/params-from-client/params-from-client.hook.test.ts @@ -1,22 +1,24 @@ -import { vi } from 'vitest' -import type { HookContext } from '@feathersjs/feathers' +import { expectTypeOf, vi } from 'vitest' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { paramsFromClient } from './params-from-client.hook.js' describe('paramsFromClient', () => { it('should move params to query._$client', () => { - expect( - paramsFromClient(['a', 'b'])({ - params: { - query: { - _$client: { - a: 1, - b: 2, - }, - c: 3, + const context = { + params: { + query: { + _$client: { + a: 1, + b: 2, }, + c: 3, }, - } as HookContext), - ).toEqual({ + }, + } as HookContext + paramsFromClient(['a', 'b'])(context) + expect(context).toEqual({ params: { a: 1, b: 2, @@ -28,19 +30,19 @@ describe('paramsFromClient', () => { }) it('should move params to query._$client and leave remaining', () => { - expect( - paramsFromClient('a')({ - params: { - query: { - _$client: { - a: 1, - b: 2, - }, - c: 3, + const context = { + params: { + query: { + _$client: { + a: 1, + b: 2, }, + c: 3, }, - } as HookContext), - ).toEqual({ + }, + } as HookContext + paramsFromClient('a')(context) + expect(context).toEqual({ params: { a: 1, query: { @@ -96,4 +98,41 @@ describe('paramsFromClient', () => { }) }) }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + expectTypeOf(paramsFromClient('user')).toExtend< + AroundHookFunction> + >() + }) + + it('unpacks _$client into params on the server side', async () => { + const app = feathers() + app.use('items', new MemoryService()) + + let seenUser: any + app.service('items').hooks({ + around: { + create: [ + paramsFromClient('user'), + async (ctx, next) => { + seenUser = (ctx.params as any).user + await next() + }, + ], + }, + }) + + await app.service('items').create({ name: 'Alice' }, { + query: { _$client: { user: { id: 5 } } }, + } as any) + + expect(seenUser).toEqual({ id: 5 }) + }) + }) }) diff --git a/src/hooks/params-from-client/params-from-client.hook.ts b/src/hooks/params-from-client/params-from-client.hook.ts index bad5cc2..b2ee54f 100644 --- a/src/hooks/params-from-client/params-from-client.hook.ts +++ b/src/hooks/params-from-client/params-from-client.hook.ts @@ -33,13 +33,18 @@ export const paramsFromClient = ( ) => { const whitelistArr = toArray(whitelist) const { keyToHide = FROM_CLIENT_FOR_SERVER_DEFAULT_KEY } = options || {} - return (context: HookContext, next?: NextFunction) => { + function hook(context: HookContext): void + function hook(context: HookContext, next: NextFunction): Promise + function hook( + context: HookContext, + next?: NextFunction, + ): void | Promise { if ( !context.params?.query?.[keyToHide] || typeof context.params.query[keyToHide] !== 'object' ) { if (next) return next() - return context + return } const params = { @@ -67,10 +72,9 @@ export const paramsFromClient = ( context.params = params - if (next) { - return next() - } + if (next) return next() - return context + return } + return hook } diff --git a/src/hooks/prevent-changes/prevent-changes.hook.test.ts b/src/hooks/prevent-changes/prevent-changes.hook.test.ts index f53b0c1..01a4461 100755 --- a/src/hooks/prevent-changes/prevent-changes.hook.test.ts +++ b/src/hooks/prevent-changes/prevent-changes.hook.test.ts @@ -139,67 +139,60 @@ describe('preventChanges', () => { }) it('does not delete if props not found', async () => { - let context: any = await preventChanges(['name', 'address'], { - error: false, - })(clone(hookBefore)) + let context: any = clone(hookBefore) + await preventChanges(['name', 'address'], { error: false })(context) assert.deepEqual(context, hookBefore) - context = await preventChanges(['name.x', 'x.y.z'], { error: false })( - clone(hookBefore), - ) + context = clone(hookBefore) + await preventChanges(['name.x', 'x.y.z'], { error: false })(context) assert.deepEqual(context, hookBefore) }) it('deletes if props found', async () => { - let context: any = await preventChanges(['name', 'first'], { - error: false, - })(clone(hookBefore)) + let context: any = clone(hookBefore) + await preventChanges(['name', 'first'], { error: false })(context) assert.deepEqual( context.data, { last: 'Doe', a: { b: 'john', c: { d: { e: 1 } } } }, '1', ) - context = await preventChanges(['name', 'a'], { error: false })( - clone(hookBefore), - ) + context = clone(hookBefore) + await preventChanges(['name', 'a'], { error: false })(context) assert.deepEqual(context.data, { first: 'John', last: 'Doe' }, '2') - context = await preventChanges(['name', 'a.b'], { error: false })( - clone(hookBefore), - ) + context = clone(hookBefore) + await preventChanges(['name', 'a.b'], { error: false })(context) assert.deepEqual( context.data, { first: 'John', last: 'Doe', a: { c: { d: { e: 1 } } } }, '3', ) - context = await preventChanges(['name', 'a.c'], { error: false })( - clone(hookBefore), - ) + context = clone(hookBefore) + await preventChanges(['name', 'a.c'], { error: false })(context) assert.deepEqual( context.data, { first: 'John', last: 'Doe', a: { b: 'john' } }, '4', ) - context = await preventChanges(['name', 'a.c.d.e'], { error: false })( - clone(hookBefore), - ) + context = clone(hookBefore) + await preventChanges(['name', 'a.c.d.e'], { error: false })(context) assert.deepEqual( context.data, { first: 'John', last: 'Doe', a: { b: 'john', c: { d: {} } } }, '5', ) - context = await preventChanges(['first', 'last'], { error: false })( - clone(hookBefore), - ) + context = clone(hookBefore) + await preventChanges(['first', 'last'], { error: false })(context) assert.deepEqual(context.data, { a: { b: 'john', c: { d: { e: 1 } } } }) - context = await preventChanges(['first', 'a.b', 'a.c.d.e'], { - error: false, - })(clone(hookBefore)) + context = clone(hookBefore) + await preventChanges(['first', 'a.b', 'a.c.d.e'], { error: false })( + context, + ) assert.deepEqual(context.data, { last: 'Doe', a: { c: { d: {} } } }) }) }) diff --git a/src/hooks/rate-limit/rate-limit.hook.test.ts b/src/hooks/rate-limit/rate-limit.hook.test.ts index ee2167a..3afb59f 100644 --- a/src/hooks/rate-limit/rate-limit.hook.test.ts +++ b/src/hooks/rate-limit/rate-limit.hook.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, expectTypeOf, vi } from 'vitest' import { RateLimiterMemory } from 'rate-limiter-flexible' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { rateLimit } from './rate-limit.hook.js' describe('hook - rateLimit', () => { @@ -116,4 +119,34 @@ describe('hook - rateLimit', () => { expect(next).toHaveBeenCalledOnce() }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + const rateLimiter = new RateLimiterMemory({ points: 5, duration: 1 }) + expectTypeOf(rateLimit(rateLimiter)).toExtend< + AroundHookFunction> + >() + }) + + it('rejects with TooManyRequests after exceeding limit', async () => { + const rateLimiter = new RateLimiterMemory({ points: 1, duration: 60 }) + const app = feathers() + app.use('items', new MemoryService()) + app.service('items').hooks({ + around: { + find: [rateLimit(rateLimiter)], + }, + }) + + await app.service('items').find() + await expect(app.service('items').find()).rejects.toThrow( + /Too many requests/, + ) + }) + }) }) diff --git a/src/hooks/rate-limit/rate-limit.hook.ts b/src/hooks/rate-limit/rate-limit.hook.ts index 9d68187..6ca3c47 100644 --- a/src/hooks/rate-limit/rate-limit.hook.ts +++ b/src/hooks/rate-limit/rate-limit.hook.ts @@ -37,7 +37,7 @@ export const rateLimit = ( const key = options?.key ?? ((context: HookContext) => context.path) const points = options?.points ?? (() => 1) - return async (context: H, next?: NextFunction) => { + return async (context: H, next?: NextFunction): Promise => { checkContext(context, { type: ['before', 'around'], label: 'rateLimit' }) const resolvedKey = await key(context) @@ -53,6 +53,6 @@ export const rateLimit = ( }) } - if (next) return await next() + if (next) await next() } } diff --git a/src/hooks/set-data/set-data.hook.test.ts b/src/hooks/set-data/set-data.hook.test.ts index e7e37fe..43ad685 100644 --- a/src/hooks/set-data/set-data.hook.test.ts +++ b/src/hooks/set-data/set-data.hook.test.ts @@ -1,5 +1,7 @@ -import { vi } from 'vitest' -import type { HookContext } from '@feathersjs/feathers' +import { expectTypeOf, vi } from 'vitest' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { setData } from './set-data.hook.js' import { Forbidden } from '@feathersjs/errors' @@ -19,10 +21,10 @@ describe('setData', function () { data: {}, } as HookContext - const result = setData('params.user.id', 'userId')(context) as HookContext + setData('params.user.id', 'userId')(context) assert.strictEqual( - result.data.userId, + context.data.userId, 1, `'${method}': data has 'userId:1'`, ) @@ -45,10 +47,10 @@ it('overwrites userId for single item', function () { data: { userId: 2 }, } as HookContext - const result = setData('params.user.id', 'userId')(context) as HookContext + setData('params.user.id', 'userId')(context) assert.strictEqual( - result.data.userId, + context.data.userId, 1, `'${method}': data has 'userId:1'`, ) @@ -70,8 +72,8 @@ it('sets userId for multiple items', function () { data: [{}, {}, {}], } as HookContext - const result = setData('params.user.id', 'userId')(context) as HookContext - result.data.forEach((item: any) => { + setData('params.user.id', 'userId')(context) + context.data.forEach((item: any) => { assert.strictEqual(item.userId, 1, `'${method}': data has 'userId:1'`) }) }) @@ -92,8 +94,8 @@ it('overwrites userId for multiple items', function () { data: [{ userId: 2 }, {}, { userId: 'abc' }], } as HookContext - const result = setData('params.user.id', 'userId')(context) as HookContext - result.data.forEach((item: any) => { + setData('params.user.id', 'userId')(context) + context.data.forEach((item: any) => { assert.strictEqual(item.userId, 1, `'${method}': data has 'userId:1'`) }) }) @@ -109,10 +111,10 @@ it("does not change createdById if 'params.user.id' is not provided", function ( data: { userId: 2 }, } as HookContext - const result = setData('params.user.id', 'userId')(context) as HookContext + setData('params.user.id', 'userId')(context) assert.strictEqual( - result.data.userId, + context.data.userId, 2, `'${method}': data has 'userId:2'`, ) @@ -194,12 +196,12 @@ describe('overwrite: false', function () { data: {}, } as unknown as HookContext - const result = setData('params.user.id', 'userId', { + setData('params.user.id', 'userId', { overwrite: false, - })(context) as HookContext + })(context) assert.strictEqual( - result.data.userId, + context.data.userId, 1, `'${method}': data has 'userId:1'`, ) @@ -221,12 +223,12 @@ describe('overwrite: false', function () { data: { userId: 2 }, } as unknown as HookContext - const result = setData('params.user.id', 'userId', { + setData('params.user.id', 'userId', { overwrite: false, - })(context) as HookContext + })(context) assert.strictEqual( - result.data.userId, + context.data.userId, 2, `'${method}': data has 'userId:2'`, ) @@ -248,11 +250,11 @@ describe('overwrite: false', function () { data: [{}, {}, {}], } as unknown as HookContext - const result = setData('params.user.id', 'userId', { + setData('params.user.id', 'userId', { overwrite: false, - })(context) as HookContext + })(context) - result.data.forEach((item: any) => { + context.data.forEach((item: any) => { assert.strictEqual(item.userId, 1, `${method}': data has 'userId:1'`) }) }) @@ -273,11 +275,11 @@ describe('overwrite: false', function () { data: [{ userId: 0 }, {}, { userId: 2 }], } as unknown as HookContext - const result = setData('params.user.id', 'userId', { + setData('params.user.id', 'userId', { overwrite: false, - })(context) as HookContext + })(context) - result.data.forEach((item: any, i: any) => { + context.data.forEach((item: any, i: any) => { assert.strictEqual(item.userId, i, `${method}': data has 'userId:${i}`) }) }) @@ -300,11 +302,11 @@ describe('overwrite: predicate', function () { data: [{ userId: 2 }, {}, { userId: 'abc' }], } as unknown as HookContext - const result = setData('params.user.id', 'userId', { + setData('params.user.id', 'userId', { overwrite: () => true, - })(context) as HookContext + })(context) - result.data.forEach((item: any) => { + context.data.forEach((item: any) => { assert.strictEqual(item.userId, 1, `'${method}': data has 'userId:1'`) }) }) @@ -325,12 +327,12 @@ describe('overwrite: predicate', function () { data: { userId: 2 }, } as unknown as HookContext - const result = setData('params.user.id', 'userId', { + setData('params.user.id', 'userId', { overwrite: (item: any) => item.userId == null, - })(context) as HookContext + })(context) assert.strictEqual( - result.data.userId, + context.data.userId, 2, `'${method}': data has 'userId:2'`, ) @@ -352,12 +354,12 @@ describe('overwrite: predicate', function () { data: { userId: 2 }, } as unknown as HookContext - const result = setData('params.user.id', 'userId', { + setData('params.user.id', 'userId', { overwrite: (item: any, context) => context.type === 'before', - })(context) as HookContext + })(context) assert.strictEqual( - result.data.userId, + context.data.userId, 1, `'${method}': data has 'userId:1'`, ) @@ -379,11 +381,11 @@ describe('overwrite: predicate', function () { data: [{ userId: 0 }, {}, { userId: 2 }], } as unknown as HookContext - const result = setData('params.user.id', 'userId', { + setData('params.user.id', 'userId', { overwrite: (item) => item.userId == null, - })(context) as HookContext + })(context) - result.data.forEach((item: any, i: any) => { + context.data.forEach((item: any, i: any) => { assert.strictEqual(item.userId, i, `${method}': data has 'userId:${i}`) }) }) @@ -468,4 +470,32 @@ describe('around hooks', function () { ) expect(next).not.toHaveBeenCalled() }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string; userId?: number } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + expectTypeOf(setData('params.user.id', 'userId')).toExtend< + AroundHookFunction> + >() + }) + + it('sets userId from params before create', async () => { + const app = feathers() + app.use('items', new MemoryService()) + app.service('items').hooks({ + around: { + create: [setData('params.user.id', 'userId')], + }, + }) + + const created = await app + .service('items') + .create({ name: 'a' }, { user: { id: 42 } } as any) + expect(created.userId).toBe(42) + }) + }) }) diff --git a/src/hooks/set-data/set-data.hook.ts b/src/hooks/set-data/set-data.hook.ts index a2798ce..01ceaee 100644 --- a/src/hooks/set-data/set-data.hook.ts +++ b/src/hooks/set-data/set-data.hook.ts @@ -68,7 +68,9 @@ export function setData( ) { const { allowUndefined = false, overwrite = true } = options ?? {} - return (context: H, next?: NextFunction) => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { const { data } = getDataIsArray(context) const contextJson = contextToJson(context) @@ -76,7 +78,7 @@ export function setData( if (!_has(contextJson, from)) { if (!context.params?.provider || allowUndefined === true) { if (next) return next() - return context + return } if ( @@ -84,7 +86,7 @@ export function setData( data.every((item: Record) => _has(item, to)) ) { if (next) return next() - return context + return } throw options?.error @@ -107,10 +109,9 @@ export function setData( _set(item, to, val) } - if (next) { - return next() - } + if (next) return next() - return context + return } + return hook } diff --git a/src/hooks/set-field/set-field.hook.test.ts b/src/hooks/set-field/set-field.hook.test.ts index 22fa369..2c8666c 100644 --- a/src/hooks/set-field/set-field.hook.test.ts +++ b/src/hooks/set-field/set-field.hook.test.ts @@ -1,9 +1,14 @@ -import { assert, expect, vi } from 'vitest' +import { assert, expect, expectTypeOf, vi } from 'vitest' import { feathers } from '@feathersjs/feathers' import { MemoryService } from '@feathersjs/memory' import { setField } from './set-field.hook.js' -import type { Application, HookContext, Params } from '@feathersjs/feathers' +import type { + Application, + AroundHookFunction, + HookContext, + Params, +} from '@feathersjs/feathers' import { Forbidden } from '@feathersjs/errors' type ParamsWithUser = Params & { user?: { id: number; name: string } } @@ -162,4 +167,46 @@ describe('setField', () => { expect(next).not.toHaveBeenCalled() }) }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + expectTypeOf( + setField({ + from: 'params.user.id', + as: 'params.query.userId', + }), + ).toExtend>>() + }) + + it('scopes find via params.user.id', async () => { + const app = feathers() + app.use( + 'items', + new MemoryService({ + multi: true, + paginate: { default: 10, max: 50 }, + }), + ) + app.service('items').hooks({ + around: { + find: [ + setField({ from: 'params.user.id', as: 'params.query.id' }), + ], + }, + }) + + await app.service('items').create([{ name: 'a' }, { name: 'b' }]) + + const result = (await app + .service('items') + .find({ user: { id: 1 } } as any)) as any + expect(result.data).toHaveLength(1) + expect(result.data[0].id).toBe(1) + }) + }) }) diff --git a/src/hooks/set-field/set-field.hook.ts b/src/hooks/set-field/set-field.hook.ts index 286172e..dcdaa29 100644 --- a/src/hooks/set-field/set-field.hook.ts +++ b/src/hooks/set-field/set-field.hook.ts @@ -42,14 +42,15 @@ export interface SetFieldOptions { * * @see https://utils.feathersjs.com/hooks/set-field.html */ -export const setField = - ({ - as, - from, - allowUndefined = false, - error, - }: SetFieldOptions) => - (context: H, next?: NextFunction) => { +export const setField = ({ + as, + from, + allowUndefined = false, + error, +}: SetFieldOptions) => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { const { params } = context checkContext(context, { type: ['before', 'around'], label: 'setField' }) @@ -59,7 +60,7 @@ export const setField = if (value === undefined) { if (!params.provider || allowUndefined) { if (next) return next() - return context + return } throw error @@ -71,5 +72,7 @@ export const setField = if (next) return next() - return context + return } + return hook +} diff --git a/src/hooks/set-result/set-result.hook.test.ts b/src/hooks/set-result/set-result.hook.test.ts index 703f9d3..6c57990 100644 --- a/src/hooks/set-result/set-result.hook.test.ts +++ b/src/hooks/set-result/set-result.hook.test.ts @@ -1,5 +1,7 @@ -import { vi } from 'vitest' -import type { HookContext } from '@feathersjs/feathers' +import { expectTypeOf, vi } from 'vitest' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { setResult } from './set-result.hook.js' import { Forbidden } from '@feathersjs/errors' @@ -19,13 +21,13 @@ describe('setResult', function () { result: {}, } as HookContext - const result = setResult( + setResult( 'params.user.id', 'userId', - )(context) as HookContext + )(context) assert.strictEqual( - result.result.userId, + context.result.userId, 1, `'${method}': result has 'userId:1'`, ) @@ -47,13 +49,13 @@ describe('setResult', function () { result: { userId: 2 }, } as HookContext - const result = setResult( + setResult( 'params.user.id', 'userId', - )(context) as HookContext + )(context) assert.strictEqual( - result.result.userId, + context.result.userId, 1, `'${method}': result has 'userId:1'`, ) @@ -75,11 +77,11 @@ describe('setResult', function () { result: [{}, {}, {}], } as HookContext - const result = setResult( + setResult( 'params.user.id', 'userId', - )(context) as HookContext - result.result.forEach((item: any) => { + )(context) + context.result.forEach((item: any) => { assert.strictEqual(item.userId, 1, `'${method}': result has 'userId:1'`) }) }) @@ -100,11 +102,11 @@ describe('setResult', function () { result: [{ userId: 2 }, {}, { userId: 'abc' }], } as HookContext - const result = setResult( + setResult( 'params.user.id', 'userId', - )(context) as HookContext - result.result.forEach((item: any) => { + )(context) + context.result.forEach((item: any) => { assert.strictEqual(item.userId, 1, `'${method}': result has 'userId:1'`) }) }) @@ -121,13 +123,13 @@ describe('setResult', function () { result: { userId: 2 }, } as HookContext - const result = setResult( + setResult( 'params.user.id', 'userId', - )(context) as HookContext + )(context) assert.strictEqual( - result.result.userId, + context.result.userId, 2, `'${method}': result has 'userId:2'`, ) @@ -212,12 +214,12 @@ describe('setResult', function () { result: {}, } as unknown as HookContext - const result = setResult('params.user.id', 'userId', { + setResult('params.user.id', 'userId', { overwrite: false, - })(context) as HookContext + })(context) assert.strictEqual( - result.result.userId, + context.result.userId, 1, `'${method}': result has 'userId:1'`, ) @@ -240,12 +242,12 @@ describe('setResult', function () { result: { userId: 2 }, } as unknown as HookContext - const result = setResult('params.user.id', 'userId', { + setResult('params.user.id', 'userId', { overwrite: false, - })(context) as HookContext + })(context) assert.strictEqual( - result.result.userId, + context.result.userId, 2, `'${method}': result has 'userId:2'`, ) @@ -267,11 +269,11 @@ describe('setResult', function () { result: [{}, {}, {}], } as unknown as HookContext - const result = setResult('params.user.id', 'userId', { + setResult('params.user.id', 'userId', { overwrite: false, - })(context) as HookContext + })(context) - result.result.forEach((item: any) => { + context.result.forEach((item: any) => { assert.strictEqual(item.userId, 1, `'${method}': result has 'userId:1'`) }) }) @@ -292,11 +294,11 @@ describe('setResult', function () { result: [{ userId: 0 }, {}, { userId: 2 }], } as unknown as HookContext - const result = setResult('params.user.id', 'userId', { + setResult('params.user.id', 'userId', { overwrite: false, - })(context) as HookContext + })(context) - result.result.forEach((item: any, i: any) => { + context.result.forEach((item: any, i: any) => { assert.strictEqual( item.userId, i, @@ -323,11 +325,11 @@ describe('overwrite: predicate', function () { result: [{ userId: 2 }, {}, { userId: 'abc' }], } as unknown as HookContext - const result = setResult('params.user.id', 'userId', { + setResult('params.user.id', 'userId', { overwrite: () => true, - })(context) as HookContext + })(context) - result.result.forEach((item: any) => { + context.result.forEach((item: any) => { assert.strictEqual(item.userId, 1, `'${method}': result has 'userId:1'`) }) }) @@ -348,12 +350,12 @@ describe('overwrite: predicate', function () { result: { userId: 2 }, } as unknown as HookContext - const result = setResult('params.user.id', 'userId', { + setResult('params.user.id', 'userId', { overwrite: (item: any) => item.userId == null, - })(context) as HookContext + })(context) assert.strictEqual( - result.result.userId, + context.result.userId, 2, `'${method}': result has 'userId:2'`, ) @@ -375,12 +377,12 @@ describe('overwrite: predicate', function () { result: { userId: 2 }, } as unknown as HookContext - const result = setResult('params.user.id', 'userId', { + setResult('params.user.id', 'userId', { overwrite: (item: any, context) => context.type === 'before', - })(context) as HookContext + })(context) assert.strictEqual( - result.result.userId, + context.result.userId, 2, `'${method}': result has 'userId:2'`, ) @@ -402,11 +404,11 @@ describe('overwrite: predicate', function () { result: [{ userId: 0 }, {}, { userId: 2 }], } as unknown as HookContext - const result = setResult('params.user.id', 'userId', { + setResult('params.user.id', 'userId', { overwrite: (item) => item.userId == null, - })(context) as HookContext + })(context) - result.result.forEach((item: any, i: any) => { + context.result.forEach((item: any, i: any) => { assert.strictEqual( item.userId, i, @@ -484,3 +486,35 @@ describe('around hooks', function () { expect(next).toHaveBeenCalledOnce() }) }) + +describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string; currentUserId?: number } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + expectTypeOf(setResult('params.user.id', 'currentUserId')).toExtend< + AroundHookFunction> + >() + }) + + it('decorates result.currentUserId from params after find', async () => { + const app = feathers() + app.use('items', new MemoryService({ multi: true })) + app.service('items').hooks({ + around: { + find: [setResult('params.user.id', 'currentUserId')], + }, + }) + + await app.service('items').create([{ name: 'a' }, { name: 'b' }]) + + const result = (await app + .service('items') + .find({ user: { id: 7 }, paginate: false } as any)) as unknown as Item[] + expect(result).toHaveLength(2) + expect(result[0].currentUserId).toBe(7) + expect(result[1].currentUserId).toBe(7) + }) +}) diff --git a/src/hooks/set-result/set-result.hook.ts b/src/hooks/set-result/set-result.hook.ts index 4f53a90..5b74023 100644 --- a/src/hooks/set-result/set-result.hook.ts +++ b/src/hooks/set-result/set-result.hook.ts @@ -118,11 +118,17 @@ export function setResult( return forResultOrDispatch(context, !!options?.dispatch) } - return (context: H, next?: NextFunction) => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { if (next) { - return next().then(() => fn(context)) + return next().then(() => { + fn(context) + }) } - return fn(context) + fn(context) + return } + return hook } diff --git a/src/hooks/set-slug/set-slug.hook.test.ts b/src/hooks/set-slug/set-slug.hook.test.ts index ce46b73..41c17e5 100755 --- a/src/hooks/set-slug/set-slug.hook.test.ts +++ b/src/hooks/set-slug/set-slug.hook.test.ts @@ -1,4 +1,7 @@ -import { assert } from 'vitest' +import { assert, expectTypeOf } from 'vitest' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { setSlug } from './set-slug.hook.js' @@ -54,4 +57,40 @@ describe('services setSlug', () => { assert.deepEqual(hook.params.query, { a: 'a', slugger: '123' }) }) }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; storeId?: string } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + expectTypeOf(setSlug('storeId')).toExtend< + AroundHookFunction> + >() + }) + + it('copies slug into params.query on rest provider', async () => { + const app = feathers() + app.use('items', new MemoryService({ multi: true })) + app.service('items').hooks({ + around: { + find: [setSlug('storeId')], + }, + }) + + await app.service('items').create([ + { storeId: '1' }, + { storeId: '2' }, + ] as any) + + const result = (await app.service('items').find({ + provider: 'rest', + route: { storeId: '1' }, + paginate: false, + } as any)) as unknown as Item[] + expect(result).toHaveLength(1) + expect(result[0].storeId).toBe('1') + }) + }) }) diff --git a/src/hooks/set-slug/set-slug.hook.ts b/src/hooks/set-slug/set-slug.hook.ts index 50a339e..8617e01 100755 --- a/src/hooks/set-slug/set-slug.hook.ts +++ b/src/hooks/set-slug/set-slug.hook.ts @@ -21,20 +21,22 @@ export const setSlug = ( slug: string, fieldName?: string, ) => { - if (typeof fieldName !== 'string') { - fieldName = `query.${slug}` - } + const targetField: string = + typeof fieldName === 'string' ? fieldName : `query.${slug}` - return (context: H, next?: NextFunction) => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { if (context.params && context.params.provider === 'rest') { const value = context.params.route[slug] if (typeof value === 'string' && value[0] !== ':') { - _set(context.params, fieldName, value) + _set(context.params, targetField, value) } } if (next) return next() - return context + return } + return hook } diff --git a/src/hooks/skippable/skippable.hook.test.ts b/src/hooks/skippable/skippable.hook.test.ts index d58c5fb..6599c74 100644 --- a/src/hooks/skippable/skippable.hook.test.ts +++ b/src/hooks/skippable/skippable.hook.test.ts @@ -1,3 +1,7 @@ +import { expectTypeOf } from 'vitest' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { shouldSkip } from '../../predicates/should-skip/should-skip.predicate.js' import { skippable } from './skippable.hook.js' @@ -5,7 +9,6 @@ describe('skippable', () => { it('runs hook when not skipped', async () => { const fn = vi.fn((context) => { context.result = { data: 'test' } - return context }) const hook = { @@ -13,13 +16,14 @@ describe('skippable', () => { method: 'create', params: { skipHooks: [] }, } - const context = { ...hook, result: null } + const context = { ...hook, result: null as any } const skippableHook = skippable(fn, shouldSkip('testHook')) - const result = await skippableHook(context) + await skippableHook(context) - expect(result).toEqual({ ...context, result: { data: 'test' } }) + expect(context.result).toEqual({ data: 'test' }) + expect(fn).toHaveBeenCalledOnce() }) it('skips for hookName in skipHooks', async () => { @@ -175,4 +179,53 @@ describe('skippable', () => { expect(next).toHaveBeenCalledOnce() }) }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string; marked?: boolean } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + const inner = (_ctx: Ctx) => {} + expectTypeOf(skippable(inner, shouldSkip('inner'))).toExtend< + AroundHookFunction> + >() + }) + + const innerHook = (ctx: Ctx, next?: any) => { + ;(ctx.data as Item).marked = true + if (next) return next() + } + + it('skips wrapped hook when skipHooks matches', async () => { + const app = feathers() + app.use('items', new MemoryService()) + + app.service('items').hooks({ + around: { + create: [skippable(innerHook, shouldSkip('inner'))], + }, + }) + + const created = await app + .service('items') + .create({ name: 'Alice' }, { skipHooks: ['inner'] } as any) + expect(created.marked).toBeUndefined() + }) + + it('runs wrapped hook when not skipped', async () => { + const app = feathers() + app.use('items', new MemoryService()) + + app.service('items').hooks({ + around: { + create: [skippable(innerHook, shouldSkip('inner'))], + }, + }) + + const created = await app.service('items').create({ name: 'Alice' }) + expect(created.marked).toBe(true) + }) + }) }) diff --git a/src/hooks/skippable/skippable.hook.ts b/src/hooks/skippable/skippable.hook.ts index e47e46f..2ca692c 100644 --- a/src/hooks/skippable/skippable.hook.ts +++ b/src/hooks/skippable/skippable.hook.ts @@ -15,26 +15,29 @@ import type { HookFunction, PredicateFn } from '../../types.js' * * @see https://utils.feathersjs.com/hooks/skippable.html */ -export const skippable = - ( - hook: HookFunction, - predicate: PredicateFn, - ) => - (context: H, next?: NextFunction) => { +export const skippable = ( + innerHook: HookFunction, + predicate: PredicateFn, +) => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { const skip = predicate(context) - function skipOrRun(skip: boolean) { - if (skip) { + const skipOrRun = (shouldSkip: boolean): void | Promise => { + if (shouldSkip) { if (next) return next() - return context - } else { - return hook(context, next) + return } + if (next) return innerHook(context, next) as Promise + innerHook(context) } if (!skip || typeof skip === 'boolean') { return skipOrRun(skip) } - return skip.then(skipOrRun) + return skip.then(skipOrRun) as Promise } + return hook +} diff --git a/src/hooks/soft-delete/soft-delete.hook.test.ts b/src/hooks/soft-delete/soft-delete.hook.test.ts index a0d9f4b..99c5982 100755 --- a/src/hooks/soft-delete/soft-delete.hook.test.ts +++ b/src/hooks/soft-delete/soft-delete.hook.test.ts @@ -1,6 +1,7 @@ -import { assert, expect } from 'vitest' +import { assert, expect, expectTypeOf } from 'vitest' import { feathers } from '@feathersjs/feathers' import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { softDelete } from './soft-delete.hook.js' async function setup(options: { type: 'before' | 'around' }) { @@ -210,4 +211,18 @@ describe('softDelete', () => { testForHookType('before') testForHookType('around') + + it('is type-compatible with AroundHookFunction', () => { + type Item = { id: number; deletedAt: Date | null } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + expectTypeOf( + softDelete({ + deletedQuery: { deletedAt: null }, + removeData: { deletedAt: new Date() }, + }), + ).toExtend>>() + }) }) diff --git a/src/hooks/soft-delete/soft-delete.hook.ts b/src/hooks/soft-delete/soft-delete.hook.ts index a99d951..8a1b841 100755 --- a/src/hooks/soft-delete/soft-delete.hook.ts +++ b/src/hooks/soft-delete/soft-delete.hook.ts @@ -65,13 +65,14 @@ export const softDelete = ( ) } - return async (context: H, next?: NextFunction) => { + return async (context: H, next?: NextFunction): Promise => { checkContext(context, { type: ['before', 'around'], label: 'softDelete' }) const { disableSoftDeleteKey = 'disableSoftDelete' } = options if (context.params[disableSoftDeleteKey]) { - return early(context, next) + await early(context, next) + return } const { deletedQuery, removeData } = options @@ -105,10 +106,8 @@ export const softDelete = ( } if (next) { - return await next() + await next() } - - return context } } diff --git a/src/hooks/stashable/stashable.hook.test.ts b/src/hooks/stashable/stashable.hook.test.ts index 70aa15e..5a68bdf 100644 --- a/src/hooks/stashable/stashable.hook.test.ts +++ b/src/hooks/stashable/stashable.hook.test.ts @@ -1,5 +1,9 @@ -import { assert, expect } from 'vitest' -import type { Application } from '@feathersjs/feathers' +import { assert, expect, expectTypeOf } from 'vitest' +import type { + Application, + AroundHookFunction, + HookContext, +} from '@feathersjs/feathers' import { feathers } from '@feathersjs/feathers' import { MemoryService } from '@feathersjs/memory' import { stashable } from './stashable.hook.js' @@ -252,4 +256,15 @@ describe('stashable', () => { const stashed = await params2.stashed() expect(stashed).toBeUndefined() }) + + it('is type-compatible with AroundHookFunction', () => { + type Item = { id: number; name: string } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + expectTypeOf(stashable()).toExtend< + AroundHookFunction> + >() + }) }) diff --git a/src/hooks/stashable/stashable.hook.ts b/src/hooks/stashable/stashable.hook.ts index ae03f30..783816b 100644 --- a/src/hooks/stashable/stashable.hook.ts +++ b/src/hooks/stashable/stashable.hook.ts @@ -44,17 +44,16 @@ const defaultStashFunc = (context: HookContext) => { */ export function stashable( options?: StashableOptions, -): { - (context: H, next: NextFunction): Promise - (context: H): H -} { +) { const propName = options?.propName ?? 'stashed' const stashFunc = options?.stashFunc ?? defaultStashFunc - return ((context: H, next?: NextFunction) => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { if (context.params._stashable) { if (next) return next() - return context + return } checkContext(context, { @@ -68,6 +67,8 @@ export function stashable( context.params[propName] = () => promise if (next) return next() - return context - }) as any + + return + } + return hook } diff --git a/src/hooks/throw-if/throw-if.hook.test.ts b/src/hooks/throw-if/throw-if.hook.test.ts index 59c7a04..fd53b52 100644 --- a/src/hooks/throw-if/throw-if.hook.test.ts +++ b/src/hooks/throw-if/throw-if.hook.test.ts @@ -1,6 +1,9 @@ +import { expectTypeOf } from 'vitest' import { BadRequest, GeneralError } from '@feathersjs/errors' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { throwIf } from './throw-if.hook.js' -import type { HookContext } from '@feathersjs/feathers' describe('throwIf', () => { it('throws BadRequest if no error function is provided', async () => { @@ -24,4 +27,32 @@ describe('throwIf', () => { })({} as HookContext), ).rejects.toThrow(GeneralError) }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + expectTypeOf(throwIf(() => false)).toExtend< + AroundHookFunction> + >() + }) + + it('blocks the call when predicate is true', async () => { + const app = feathers() + app.use('items', new MemoryService()) + app.service('items').hooks({ + around: { + remove: [throwIf((ctx) => ctx.id != null)], + }, + }) + + const created = await app.service('items').create({ name: 'Alice' }) + await expect(app.service('items').remove(created.id)).rejects.toThrow( + BadRequest, + ) + }) + }) }) diff --git a/src/hooks/throw-if/throw-if.hook.ts b/src/hooks/throw-if/throw-if.hook.ts index 6ce53a2..0ac63e5 100644 --- a/src/hooks/throw-if/throw-if.hook.ts +++ b/src/hooks/throw-if/throw-if.hook.ts @@ -32,7 +32,7 @@ export const throwIf = ( predicate: PredicateFn, options?: ThrowIfOptions, ) => { - return async (context: H, next?: NextFunction) => { + return async (context: H, next?: NextFunction): Promise => { const result = await predicate(context) if (result) { @@ -42,7 +42,7 @@ export const throwIf = ( } if (next) { - return await next() + await next() } } } diff --git a/src/hooks/transform-data/transform-data.hook.test.ts b/src/hooks/transform-data/transform-data.hook.test.ts index f0043dd..d5c3495 100755 --- a/src/hooks/transform-data/transform-data.hook.test.ts +++ b/src/hooks/transform-data/transform-data.hook.test.ts @@ -1,4 +1,7 @@ -import { assert } from 'vitest' +import { assert, expectTypeOf } from 'vitest' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { transformData } from './transform-data.hook.js' let hookBefore: any @@ -61,16 +64,20 @@ describe('transformData', () => { }) }) - it('returns a promise that contains context', async () => { + it('returns a promise that resolves once context is mutated', async () => { const promise = transformData(async (item: any) => { item.state = 'UT' })(hookBefore) assert.ok(promise instanceof Promise) - const result = await promise + await promise - assert.deepEqual(result, hookBefore) + assert.deepEqual(hookBefore.data, { + first: 'John', + last: 'Doe', + state: 'UT', + }) }) it('updates hook before::create with new item returned', async () => { @@ -112,4 +119,32 @@ describe('transformData', () => { state: 'UT', }) }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string; state?: string } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + expectTypeOf(transformData(() => {})).toExtend< + AroundHookFunction> + >() + }) + + it('adds state field before create', async () => { + const app = feathers() + app.use('items', new MemoryService()) + app.service('items').hooks({ + around: { + create: [ + transformData((item) => ({ ...item, state: 'UT' })), + ], + }, + }) + + const created = await app.service('items').create({ name: 'Alice' }) + expect(created.state).toBe('UT') + }) + }) }) diff --git a/src/hooks/transform-data/transform-data.hook.ts b/src/hooks/transform-data/transform-data.hook.ts index 65ce3e9..4ba10ff 100755 --- a/src/hooks/transform-data/transform-data.hook.ts +++ b/src/hooks/transform-data/transform-data.hook.ts @@ -29,12 +29,10 @@ export const transformData = >( transformer: TransformerInputFn, ) => - async (context: H, next?: NextFunction) => { + async (context: H, next?: NextFunction): Promise => { await mutateData(context, transformer) if (next) { - return next() + await next() } - - return context } diff --git a/src/hooks/transform-query/transform-query.hook.test.ts b/src/hooks/transform-query/transform-query.hook.test.ts index c19117c..50507fb 100644 --- a/src/hooks/transform-query/transform-query.hook.test.ts +++ b/src/hooks/transform-query/transform-query.hook.test.ts @@ -1,4 +1,7 @@ -import type { HookContext } from '@feathersjs/feathers' +import { expectTypeOf } from 'vitest' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { transformQuery } from './transform-query.hook.js' describe('transformQuery', () => { @@ -19,12 +22,48 @@ describe('transformQuery', () => { } } - const result = transformQuery(transformer)(context) + transformQuery(transformer)(context) - expect((result as any).params.query).toEqual({ + expect(context.params.query).toEqual({ foo: 'bar', baz: 'qux', transformed: true, }) }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string; active?: boolean } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + expectTypeOf(transformQuery((q) => q)).toExtend< + AroundHookFunction> + >() + }) + + it('augments query before find', async () => { + const app = feathers() + app.use('items', new MemoryService({ multi: true })) + app.service('items').hooks({ + around: { + find: [ + transformQuery((q) => ({ ...q, active: true })), + ], + }, + }) + + await app.service('items').create([ + { name: 'a', active: true }, + { name: 'b', active: false }, + ] as any) + + const result = (await app + .service('items') + .find({ paginate: false } as any)) as unknown as Item[] + expect(result).toHaveLength(1) + expect(result[0].name).toBe('a') + }) + }) }) diff --git a/src/hooks/transform-query/transform-query.hook.ts b/src/hooks/transform-query/transform-query.hook.ts index c468985..1f523ce 100644 --- a/src/hooks/transform-query/transform-query.hook.ts +++ b/src/hooks/transform-query/transform-query.hook.ts @@ -23,16 +23,17 @@ export const transformQuery = < >( transformer: TransformerFn, ) => { - return (context: H, next?: NextFunction) => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { context.params.query = transformer(context.params.query ?? {}, { context, i: 0, }) - if (next) { - return next().then(() => context) - } + if (next) return next() - return context + return } + return hook } diff --git a/src/hooks/transform-result/transform-result.hook.test.ts b/src/hooks/transform-result/transform-result.hook.test.ts index 3961383..380c762 100755 --- a/src/hooks/transform-result/transform-result.hook.test.ts +++ b/src/hooks/transform-result/transform-result.hook.test.ts @@ -1,6 +1,8 @@ -import { assert } from 'vitest' +import { assert, expectTypeOf } from 'vitest' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { transformResult } from './transform-result.hook.js' -import type { HookContext } from '@feathersjs/feathers' let hookAfter: any let hookFindPaginate: any @@ -245,4 +247,38 @@ describe('transformResult', () => { new: 'Jack', }) }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string; password?: string } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + expectTypeOf(transformResult(() => {})).toExtend< + AroundHookFunction> + >() + }) + + it('omits password from result after get', async () => { + const app = feathers() + app.use('items', new MemoryService()) + app.service('items').hooks({ + around: { + get: [ + transformResult((item: any) => { + delete item.password + }), + ], + }, + }) + + const created = await app + .service('items') + .create({ name: 'Alice', password: 'secret' }) + const got = await app.service('items').get(created.id) + expect((got as any).password).toBeUndefined() + expect(got.name).toBe('Alice') + }) + }) }) diff --git a/src/hooks/transform-result/transform-result.hook.ts b/src/hooks/transform-result/transform-result.hook.ts index bbc3df6..f125bfa 100755 --- a/src/hooks/transform-result/transform-result.hook.ts +++ b/src/hooks/transform-result/transform-result.hook.ts @@ -1,10 +1,6 @@ import type { HookContext, NextFunction } from '@feathersjs/feathers' import { mutateResult } from '../../utils/mutate-result/mutate-result.util.js' -import type { - DispatchOption, - HookFunction, - TransformerInputFn, -} from '../../types.js' +import type { DispatchOption, TransformerInputFn } from '../../types.js' import type { ResultSingleHookContext } from '../../utility-types/hook-context.js' import type { AnyFallback } from '../../internal.utils.js' @@ -37,9 +33,10 @@ export const transformResult = >( transformer: TransformerInputFn, options?: TransformResultOptions, - ): HookFunction => - (context: H, next?: NextFunction) => - mutateResult(context, transformer, { + ) => + async (context: H, next?: NextFunction): Promise => { + await mutateResult(context, transformer, { next, dispatch: options?.dispatch, - }) as Promise + }) + } diff --git a/src/hooks/traverse/traverse.hook.test.ts b/src/hooks/traverse/traverse.hook.test.ts index 2d97cf5..8e4ead2 100755 --- a/src/hooks/traverse/traverse.hook.test.ts +++ b/src/hooks/traverse/traverse.hook.test.ts @@ -1,4 +1,7 @@ -import { assert } from 'vitest' +import { assert, expectTypeOf } from 'vitest' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { traverse } from './traverse.hook.js' import { copy } from 'fast-copy' @@ -125,4 +128,40 @@ describe('traverse', () => { assert.deepEqual(obj, result) }) + + describe('integration with service.hooks({ around })', () => { + type Item = { id: number; name: string } + type Services = { items: MemoryService } + type App = ReturnType> + type Ctx = HookContext> + + it('is type-compatible with AroundHookFunction', () => { + expectTypeOf( + traverse({ + transformer: function () {}, + getObject: (ctx) => ctx.data, + }), + ).toExtend>>() + }) + + it('trims string data fields before create', async () => { + const app = feathers() + app.use('items', new MemoryService()) + app.service('items').hooks({ + around: { + create: [ + traverse({ + transformer(this: any, node) { + if (typeof node === 'string') this.update(node.trim()) + }, + getObject: (ctx) => ctx.data, + }), + ], + }, + }) + + const created = await app.service('items').create({ name: ' Alice ' }) + expect(created.name).toBe('Alice') + }) + }) }) diff --git a/src/hooks/traverse/traverse.hook.ts b/src/hooks/traverse/traverse.hook.ts index 84ab220..f830ef6 100755 --- a/src/hooks/traverse/traverse.hook.ts +++ b/src/hooks/traverse/traverse.hook.ts @@ -26,17 +26,18 @@ export type TraverseOptions = { * * @see https://utils.feathersjs.com/hooks/traverse.html */ -export const traverse = - ({ - transformer, - getObject, - }: TraverseOptions) => - (context: H, next?: NextFunction) => { +export const traverse = ({ + transformer, + getObject, +}: TraverseOptions) => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { _traverse(getObject(context), transformer) - if (next) { - return next() - } + if (next) return next() - return context + return } + return hook +} From e29104a3a3091e53656a166f9397c9d7f77471d0 Mon Sep 17 00:00:00 2001 From: fratzinger <22286818+fratzinger@users.noreply.github.com> Date: Tue, 12 May 2026 11:30:03 +0200 Subject: [PATCH 2/2] lint: fix lints --- src/hooks/debug/debug.hook.test.ts | 9 ++++--- src/hooks/iff-else/iff-else.hook.test.ts | 8 +++--- src/hooks/set-field/set-field.hook.ts | 2 +- src/hooks/set-result/set-result.hook.test.ts | 25 ++++--------------- src/hooks/set-slug/set-slug.hook.test.ts | 7 +++--- .../transform-data.hook.test.ts | 4 +-- .../transform-query.hook.test.ts | 4 +-- .../resolve-result/resolve-result.ts | 5 +++- test/index.test.ts | 2 +- 9 files changed, 25 insertions(+), 41 deletions(-) diff --git a/src/hooks/debug/debug.hook.test.ts b/src/hooks/debug/debug.hook.test.ts index cf8e188..ac5ea50 100755 --- a/src/hooks/debug/debug.hook.test.ts +++ b/src/hooks/debug/debug.hook.test.ts @@ -1,7 +1,10 @@ import { expectTypeOf } from 'vitest' -import { feathers } from '@feathersjs/feathers' -import { MemoryService } from '@feathersjs/memory' -import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' +import type { + feathers, + AroundHookFunction, + HookContext, +} from '@feathersjs/feathers' +import type { MemoryService } from '@feathersjs/memory' import { debug } from './debug.hook.js' describe('services debug', () => { diff --git a/src/hooks/iff-else/iff-else.hook.test.ts b/src/hooks/iff-else/iff-else.hook.test.ts index 09449f3..2da9eb2 100755 --- a/src/hooks/iff-else/iff-else.hook.test.ts +++ b/src/hooks/iff-else/iff-else.hook.test.ts @@ -128,11 +128,9 @@ describe('services iffElse', () => { }) it('when false', () => { - return iffElse( - false, - undefined, - [hookFcnSync, hookFcnAsync, hookFcn], - )(hook).then((hook: any) => { + return iffElse(false, undefined, [hookFcnSync, hookFcnAsync, hookFcn])( + hook, + ).then((hook: any) => { assert.deepEqual(hook, hookAfter) assert.equal(hookFcnSyncCalls, 1) assert.equal(hookFcnAsyncCalls, 1) diff --git a/src/hooks/set-field/set-field.hook.ts b/src/hooks/set-field/set-field.hook.ts index dcdaa29..2828d2d 100644 --- a/src/hooks/set-field/set-field.hook.ts +++ b/src/hooks/set-field/set-field.hook.ts @@ -68,7 +68,7 @@ export const setField = ({ : new Forbidden(`Expected field ${as} not available`) } - context = _setWith(context, as, value, _clone) + _setWith(context, as, value, _clone) if (next) return next() diff --git a/src/hooks/set-result/set-result.hook.test.ts b/src/hooks/set-result/set-result.hook.test.ts index 6c57990..57d5484 100644 --- a/src/hooks/set-result/set-result.hook.test.ts +++ b/src/hooks/set-result/set-result.hook.test.ts @@ -21,10 +21,7 @@ describe('setResult', function () { result: {}, } as HookContext - setResult( - 'params.user.id', - 'userId', - )(context) + setResult('params.user.id', 'userId')(context) assert.strictEqual( context.result.userId, @@ -49,10 +46,7 @@ describe('setResult', function () { result: { userId: 2 }, } as HookContext - setResult( - 'params.user.id', - 'userId', - )(context) + setResult('params.user.id', 'userId')(context) assert.strictEqual( context.result.userId, @@ -77,10 +71,7 @@ describe('setResult', function () { result: [{}, {}, {}], } as HookContext - setResult( - 'params.user.id', - 'userId', - )(context) + setResult('params.user.id', 'userId')(context) context.result.forEach((item: any) => { assert.strictEqual(item.userId, 1, `'${method}': result has 'userId:1'`) }) @@ -102,10 +93,7 @@ describe('setResult', function () { result: [{ userId: 2 }, {}, { userId: 'abc' }], } as HookContext - setResult( - 'params.user.id', - 'userId', - )(context) + setResult('params.user.id', 'userId')(context) context.result.forEach((item: any) => { assert.strictEqual(item.userId, 1, `'${method}': result has 'userId:1'`) }) @@ -123,10 +111,7 @@ describe('setResult', function () { result: { userId: 2 }, } as HookContext - setResult( - 'params.user.id', - 'userId', - )(context) + setResult('params.user.id', 'userId')(context) assert.strictEqual( context.result.userId, diff --git a/src/hooks/set-slug/set-slug.hook.test.ts b/src/hooks/set-slug/set-slug.hook.test.ts index 41c17e5..739462c 100755 --- a/src/hooks/set-slug/set-slug.hook.test.ts +++ b/src/hooks/set-slug/set-slug.hook.test.ts @@ -79,10 +79,9 @@ describe('services setSlug', () => { }, }) - await app.service('items').create([ - { storeId: '1' }, - { storeId: '2' }, - ] as any) + await app + .service('items') + .create([{ storeId: '1' }, { storeId: '2' }] as any) const result = (await app.service('items').find({ provider: 'rest', diff --git a/src/hooks/transform-data/transform-data.hook.test.ts b/src/hooks/transform-data/transform-data.hook.test.ts index d5c3495..277142f 100755 --- a/src/hooks/transform-data/transform-data.hook.test.ts +++ b/src/hooks/transform-data/transform-data.hook.test.ts @@ -137,9 +137,7 @@ describe('transformData', () => { app.use('items', new MemoryService()) app.service('items').hooks({ around: { - create: [ - transformData((item) => ({ ...item, state: 'UT' })), - ], + create: [transformData((item) => ({ ...item, state: 'UT' }))], }, }) diff --git a/src/hooks/transform-query/transform-query.hook.test.ts b/src/hooks/transform-query/transform-query.hook.test.ts index 50507fb..7c81537 100644 --- a/src/hooks/transform-query/transform-query.hook.test.ts +++ b/src/hooks/transform-query/transform-query.hook.test.ts @@ -48,9 +48,7 @@ describe('transformQuery', () => { app.use('items', new MemoryService({ multi: true })) app.service('items').hooks({ around: { - find: [ - transformQuery((q) => ({ ...q, active: true })), - ], + find: [transformQuery((q) => ({ ...q, active: true }))], }, }) diff --git a/src/resolvers/resolve-result/resolve-result.ts b/src/resolvers/resolve-result/resolve-result.ts index 97bae64..d1e3823 100644 --- a/src/resolvers/resolve-result/resolve-result.ts +++ b/src/resolvers/resolve-result/resolve-result.ts @@ -25,7 +25,10 @@ type Result = AnyFallback< * }) * ``` */ -export const resolveResult = >( +export const resolveResult = < + H extends HookContext = HookContext, + R = Result, +>( resolvers: ResolverObject, ): { (context: H, next: NextFunction): Promise diff --git a/test/index.test.ts b/test/index.test.ts index adba1ae..6ef577b 100755 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -62,7 +62,7 @@ const utils = [ 'toPaginated', 'transformParams', 'walkQuery', - "zipDataResult", + 'zipDataResult', ] satisfies (keyof typeof exportedUtils)[] const predicates = [