Skip to content
Prev Previous commit
Next Next commit
feat: hookMixin respects custom params from @hooks().params() decorator
When a service method is decorated with @hooks([]).params(...), hookMixin
now respects those custom params instead of overriding them with defaults.

This enables the use of custom method signatures:

  class MyService {
    @(hooks([]).params('userId', 'message'))
    async notify(userId: string, message: string) {}
  }

When registered with feathers, the notify method will have context.userId
and context.message available instead of the default context.data/params.

Changes:
- hookMixin checks for existing params from decorated methods
- For decorated methods, Feathers context (app, path, service, method)
  is added to the existing Context prototype instead of re-wrapping
- Added 5 tests for hookMixin respecting decorator params
  • Loading branch information
marshallswain committed Jan 31, 2026
commit ed6d65049bc232d05f3d9b7c7133bc0091c13c24
71 changes: 58 additions & 13 deletions packages/feathers/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
getManager,
setManager,
HookContextData,
HookManager,
HookMap as BaseHookMap,
Expand Down Expand Up @@ -185,23 +186,67 @@ export function hookMixin<A>(this: A, service: FeathersService<A>, path: string,

const hookMethods = getHookMethods(service, options)

// Feathers-specific props to add to all method contexts
const feathersProps: HookContextData = {
app: this,
path,
service,
event: null,
type: 'around',
get statusCode() {
return (this as any).http?.status
},
set statusCode(value: number) {
;(this as any).http = (this as any).http || {}
;(this as any).http.status = value
}
}

const serviceMethodHooks = hookMethods.reduce((res, method) => {
// Check if the method already has params configured via @hooks().params()
const existingManager = getManager((service as any)[method])
const existingParams = existingManager?.getParams()

// If the method already has custom params from a decorator, we need to
// enhance the existing wrapper with Feathers context. We do this by:
// 1. Creating a FeathersHookManager as a parent (for collectMiddleware)
// 2. Regenerating the Context class with Feathers props on the prototype
if (existingParams && existingManager) {
const wrapper = (service as any)[method]
// Set up FeathersHookManager as parent for middleware collection
const feathersManager = new FeathersHookManager<A>(this, method)
setManager(wrapper, feathersManager)
// Add Feathers props directly to the existing Context prototype
const contextProto = wrapper.Context?.prototype
if (contextProto) {
Object.defineProperties(contextProto, {
app: { value: this, enumerable: true, writable: true },
path: { value: path, enumerable: true, writable: true },
method: { value: method, enumerable: true, writable: true },
service: { value: service, enumerable: true, writable: true },
event: { value: null, enumerable: true, writable: true },
type: { value: 'around', enumerable: true, writable: true },
statusCode: {
enumerable: true,
get() {
return this.http?.status
},
set(value: number) {
this.http = this.http || {}
this.http.status = value
}
}
})
}
return res
}

// Use default params for this method
const params = (defaultServiceArguments as any)[method] || ['data', 'params']

res[method] = new FeathersHookManager<A>(this, method).params(...params).props({
app: this,
path,
method,
service,
event: null,
type: 'around',
get statusCode() {
return this.http?.status
},
set statusCode(value: number) {
this.http = this.http || {}
this.http.status = value
}
...feathersProps,
method
})

return res
Expand Down
151 changes: 151 additions & 0 deletions packages/feathers/src/hooks/decorator.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import assert from 'assert'
import { HookContext, hooks, middleware, NextFunction } from './index.js'
import { feathers } from '../index.js'

describe('feathers/hooks chainable decorator', () => {
it('supports @hooks([]).params() chainable syntax', async () => {
Expand Down Expand Up @@ -86,7 +87,7 @@
.params('id', 'params')
.props({ service: 'messages' })
.defaults(() => ({ timestamp: 99999 })))
async status(id: string, params: any) {

Check warning on line 90 in packages/feathers/src/hooks/decorator.test.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

'params' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 90 in packages/feathers/src/hooks/decorator.test.ts

View workflow job for this annotation

GitHub Actions / build (20.x)

'params' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 90 in packages/feathers/src/hooks/decorator.test.ts

View workflow job for this annotation

GitHub Actions / build (20.x)

'params' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 90 in packages/feathers/src/hooks/decorator.test.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

'params' is defined but never used. Allowed unused args must match /^_/u
return { id, status: 'active' }
}
}
Expand Down Expand Up @@ -203,3 +204,153 @@
expect(() => hooks([])({}, 'test', { value: 'not a function' })).toThrow('Can not apply hooks.')
})
})

describe('hookMixin respects @hooks().params()', () => {
it('uses custom params from @hooks().params() instead of defaults', async () => {
let capturedCtx: HookContext | null = null

class MessageService {
@(hooks([
async (ctx: HookContext, next: NextFunction) => {
capturedCtx = ctx
await next()
}
]).params('message', 'options'))
async create(message: string, options?: any) {
return { message, options }
}
}

const app = feathers().use('messages', new MessageService(), {
methods: ['create']
})

const result = await app.service('messages').create('Hello world', { notify: true })

expect(result).toEqual({ message: 'Hello world', options: { notify: true } })
expect(capturedCtx).not.toBeNull()
// The context should have message and options, NOT the default data/params
expect(capturedCtx!.message).toBe('Hello world')
expect(capturedCtx!.options).toEqual({ notify: true })
// These should be undefined since we're using custom params
expect(capturedCtx!.data).toBeUndefined()
})

it('falls back to default params when @hooks().params() is not used', async () => {
let capturedCtx: HookContext | null = null

class MessageService {
@hooks([
async (ctx: HookContext, next: NextFunction) => {
capturedCtx = ctx
await next()
}
])
async create(data: any, params?: any) {

Check warning on line 249 in packages/feathers/src/hooks/decorator.test.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

'params' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 249 in packages/feathers/src/hooks/decorator.test.ts

View workflow job for this annotation

GitHub Actions / build (20.x)

'params' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 249 in packages/feathers/src/hooks/decorator.test.ts

View workflow job for this annotation

GitHub Actions / build (20.x)

'params' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 249 in packages/feathers/src/hooks/decorator.test.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

'params' is defined but never used. Allowed unused args must match /^_/u
return data
}
}

const app = feathers().use('messages', new MessageService(), {
methods: ['create']
})

const result = await app.service('messages').create({ text: 'Hello' }, { user: 'test' })

expect(result).toEqual({ text: 'Hello' })
expect(capturedCtx).not.toBeNull()
// Should use default create params: data, params
expect(capturedCtx!.data).toEqual({ text: 'Hello' })
expect(capturedCtx!.params).toEqual({ user: 'test' })
})

it('respects custom params for custom methods', async () => {
let capturedCtx: HookContext | null = null

class NotificationService {
@(hooks([
async (ctx: HookContext, next: NextFunction) => {
capturedCtx = ctx
await next()
}
]).params('userId', 'message', 'priority'))
async notify(userId: string, message: string, priority: number) {
return { userId, message, priority, sent: true }
}
}

const app = feathers().use('notifications', new NotificationService(), {
methods: ['notify']
})

const result = await app.service('notifications').notify('user123', 'You have mail', 1)

expect(result).toEqual({ userId: 'user123', message: 'You have mail', priority: 1, sent: true })
expect(capturedCtx).not.toBeNull()
expect(capturedCtx!.userId).toBe('user123')
expect(capturedCtx!.message).toBe('You have mail')
expect(capturedCtx!.priority).toBe(1)
})

it('allows hooks to modify custom params before method execution', async () => {
class GreetingService {
@(hooks([
async (ctx: HookContext, next: NextFunction) => {
// Modify the name before method runs
ctx.name = ctx.name.toUpperCase()
await next()
}
]).params('name'))
async greet(name: string) {
return `Hello, ${name}!`
}
}

const app = feathers().use('greetings', new GreetingService(), {
methods: ['greet']
})

const result = await app.service('greetings').greet('david')

expect(result).toBe('Hello, DAVID!')
})

it('works with chained params, props, and defaults on registered service', async () => {
let capturedCtx: HookContext | null = null

class StatusService {
@(hooks([
async (ctx: HookContext, next: NextFunction) => {
capturedCtx = ctx
await next()
}
])
.params('id', 'options')
.props({ serviceName: 'status' })
.defaults(() => ({ timestamp: 99999 })))
async check(id: string, options?: any) {

Check warning on line 331 in packages/feathers/src/hooks/decorator.test.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

'options' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 331 in packages/feathers/src/hooks/decorator.test.ts

View workflow job for this annotation

GitHub Actions / build (20.x)

'options' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 331 in packages/feathers/src/hooks/decorator.test.ts

View workflow job for this annotation

GitHub Actions / build (20.x)

'options' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 331 in packages/feathers/src/hooks/decorator.test.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

'options' is defined but never used. Allowed unused args must match /^_/u
return { id, status: 'ok' }
}
}

const app = feathers().use('status', new StatusService(), {
methods: ['check']
})

const result = await app.service('status').check('server-1', { verbose: true })

expect(result).toEqual({ id: 'server-1', status: 'ok' })
expect(capturedCtx).not.toBeNull()
// Custom params
expect(capturedCtx!.id).toBe('server-1')
expect(capturedCtx!.options).toEqual({ verbose: true })
// Props from .props()
expect(capturedCtx!.serviceName).toBe('status')
// Defaults from .defaults()
expect(capturedCtx!.timestamp).toBe(99999)
// Service-level props added by hookMixin
expect(capturedCtx!.app).toBe(app)
expect(capturedCtx!.path).toBe('status')
expect(capturedCtx!.method).toBe('check')
})
})
Loading