Visitar URL original
feat: Enhanced custom methods with @method decorator by marshallswain · Pull Request #3642 · feathersjs/feathers · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: access route data property correctly for conflict detection
  • Loading branch information
marshallswain committed Jan 28, 2026
commit fb06f635ef725e9d3c79f3a58b1a919eeca4558d
84 changes: 83 additions & 1 deletion packages/feathers/src/application.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { describe, it } from 'vitest'
import assert from 'assert'
import { feathers, Feathers, getServiceOptions, Id, version } from '../src/index.js'
import { feathers, Feathers, getServiceOptions, Id, version, method } from '../src/index.js'

describe('Feathers application', () => {
it('initializes', () => {
Expand Down Expand Up @@ -563,4 +563,86 @@ describe('Feathers application', () => {
})
})
})

describe('route conflicts', () => {
it('throws when custom method path conflicts with another service custom method', () => {
const app = feathers()

class MessageService {
@method({ args: ['id', 'params'], http: 'GET', path: ':id/status' })
async status(id: Id) {
return { id, status: 'message' }
}
}

class NotificationService {
// Same path pattern - this would conflict with messages/:id/status
@method({ args: ['id', 'params'], http: 'GET', path: ':id/status' })
async status(id: Id) {
return { id, status: 'notification' }
}
}

app.use('messages', new MessageService())
app.use('notifications', new NotificationService())

// These have different base paths, so they should NOT conflict
assert.ok(app.service('messages'))
assert.ok(app.service('notifications'))
})

it('throws when custom method path conflicts with another service base path', () => {
const app = feathers()

// Register a nested service first
app.use('messages/archived', {
async find() {
return []
}
})

// Now try to register a service with a custom method that would conflict
class MessageService {
async find() {
return []
}

@method({ args: ['params'], http: 'GET', path: 'archived' })
async archived() {
return []
}
}

assert.throws(
() => app.use('messages', new MessageService()),
/Path 'messages\/archived' for method 'archived' conflicts with another service/
)
})

it('allows different custom method paths on the same service', () => {
const app = feathers()

class MessageService {
@method({ args: ['id', 'params'], http: 'GET', path: ':id/status' })
async status(id: Id) {
return { id, status: 'active' }
}

@method({ args: ['id', 'params'], http: 'POST', path: ':id/archive' })
async archive(id: Id) {
return { id, archived: true }
}

@method({ args: ['params'], http: 'GET', path: 'stats' })
async stats() {
return { total: 100 }
}
}

// Should not throw
app.use('messages', new MessageService())

assert.ok(app.service('messages'))
})
})
})
27 changes: 22 additions & 5 deletions packages/feathers/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,11 +286,28 @@ export class Feathers<Services, Settings>
const customPath = methodConfig.path.replace(/:id\b/g, ':__id')
const fullPath = `${path}/${customPath}`

this.routes.insert(fullPath, {
...routerParams,
method: methodName,
httpMethod: methodConfig.http || 'POST'
})
// The router.insert() will throw if an exact path already exists.
// This catches duplicate custom method paths and conflicts with other services.
try {
this.routes.insert(fullPath, {
...routerParams,
method: methodName,
httpMethod: methodConfig.http || 'POST'
})
} catch (error: any) {
if (error.message?.includes('already exists')) {
// Check what's already registered at this path to give a better error message
const existingRoute = this.routes.lookup(fullPath)
const existingMethod = existingRoute?.data?.method
? `method '${existingRoute.data.method}'`
: 'another service'
throw new Error(
`Path '${fullPath}' for method '${methodName}' conflicts with ${existingMethod}. ` +
`Custom method paths must be unique.`
)
}
throw error
}

debug(`Registered custom path \`${fullPath}\` for method \`${methodName}\``)
}
Expand Down
4 changes: 4 additions & 0 deletions website/content/api/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@ For more control over custom methods, you can use the `@method` decorator to con
- **Clean URL paths** (e.g., `/messages/123/status` instead of using `X-Service-Method` header)
- **Internal-only methods** (hooks run but not exposed via HTTP)

::info[Note]
There are two alternatives to the `@method` decorator. See the next sections.
::

```ts
import { feathers, method, hooks } from '@feathersjs/feathers'
import type { Id, Params } from '@feathersjs/feathers'
Expand Down
Loading