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 all commits
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
414 changes: 414 additions & 0 deletions docs/v6-custom-methods-plan.md

Large diffs are not rendered by default.

2,948 changes: 2,932 additions & 16 deletions package-lock.json

Large diffs are not rendered by default.

64 changes: 63 additions & 1 deletion packages/feathers/fixtures/index.ts
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'))
})
})
})
40 changes: 38 additions & 2 deletions packages/feathers/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,12 @@ export class Feathers<Services, Settings>

const {
params: colonParams,
data: { service, params: dataParams }
data: { service, params: dataParams, method, httpMethod }
} = result

const params = dataParams ? { ...dataParams, ...colonParams } : colonParams

return { service, params }
return { service, params, method, httpMethod }
}

protected _setup() {
Expand Down Expand Up @@ -277,6 +277,42 @@ export class Feathers<Services, Settings>

this.routes.insert(path, routerParams)
this.routes.insert(`${path}/:__id`, routerParams)

// Register routes for custom method paths
const { methodOptions } = serviceOptions
for (const [methodName, methodConfig] of Object.entries(methodOptions)) {
if (methodConfig.path && methodConfig.external !== false) {
// Convert :id in path to :__id for consistency with standard routes
const customPath = methodConfig.path.replace(/:id\b/g, ':__id')
const fullPath = `${path}/${customPath}`

// 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}\``)
}
}

this.services[location] = protoService

// If we ran setup already, set this service up explicitly, this will not `await`
Expand Down
53 changes: 53 additions & 0 deletions packages/feathers/src/client/fetch.test.ts
Loading
Loading