Skip to content

Commit d4b41ac

Browse files
committed
feat: Enhanced custom methods with @method decorator
- Add @method decorator for configuring custom service methods - Support different argument signatures (not just data, params) - Support different HTTP verbs (GET, POST, PUT, PATCH, DELETE) - Support clean URL paths (e.g., /messages/:id/status) - Support internal-only methods with external: false - Add buildMethodConfig() helper for client configuration - Add InferServiceTypes type helper for typed clients - Update REST client to support custom method paths and verbs - Update services and REST client documentation The @method decorator allows custom methods to be first-class citizens with flexible signatures, proper HTTP verb mapping, and clean URLs instead of relying on X-Service-Method headers. Closes #1976 Replaces #3638
1 parent 5194823 commit d4b41ac

File tree

19 files changed

+2767
-38
lines changed

19 files changed

+2767
-38
lines changed

docs/v6-custom-methods-plan.md

Lines changed: 414 additions & 0 deletions
Large diffs are not rendered by default.

packages/feathers/fixtures/index.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createServer } from 'node:http'
22
import { TestService } from './fixture.js'
33

4-
import { feathers, Application, Params } from '../src/index.js'
4+
import { feathers, Application, Params, method } from '../src/index.js'
55
import { createHandler, SseService } from '../src/http/index.js'
66
import { toNodeHandler } from '../src/http/node.js'
77

@@ -107,12 +107,72 @@ export class ResponseTestService {
107107
}
108108
}
109109

110+
export class InternalTestService {
111+
async find(_params: Params) {
112+
return [{ id: 1, name: 'Public' }]
113+
}
114+
115+
@method({ args: ['data', 'params'], external: false })
116+
async internalProcess(data: any, _params: Params) {
117+
return { processed: data, internal: true }
118+
}
119+
}
120+
121+
export class CustomPathService {
122+
messages: { id: string; text: string; status: string }[] = [
123+
{ id: '1', text: 'Hello', status: 'active' },
124+
{ id: '2', text: 'World', status: 'archived' }
125+
]
126+
127+
async find(_params: Params) {
128+
return this.messages
129+
}
130+
131+
async get(id: string, _params: Params) {
132+
return this.messages.find((m) => m.id === id)
133+
}
134+
135+
@method({ args: ['id', 'params'], http: 'GET', path: ':id/status' })
136+
async status(id: string, _params: Params) {
137+
const msg = this.messages.find((m) => m.id === id)
138+
return { id, status: msg?.status || 'unknown' }
139+
}
140+
141+
@method({ args: ['id', 'params'], http: 'POST', path: ':id/archive' })
142+
async archive(id: string, _params: Params) {
143+
const msg = this.messages.find((m) => m.id === id)
144+
if (msg) {
145+
msg.status = 'archived'
146+
}
147+
return msg
148+
}
149+
150+
@method({ args: ['params'], http: 'GET', path: 'stats' })
151+
async stats(_params: Params) {
152+
return {
153+
total: this.messages.length,
154+
active: this.messages.filter((m) => m.status === 'active').length
155+
}
156+
}
157+
158+
@method({ args: ['id', 'data', 'params'], http: 'POST', path: ':id/update-status' })
159+
async updateStatus(id: string, data: { status: string }, _params: Params) {
160+
const msg = this.messages.find((m) => m.id === id)
161+
if (msg) {
162+
msg.status = data.status
163+
}
164+
return msg
165+
}
166+
}
167+
110168
export type TestServiceTypes = {
111169
todos: TestService
112170
uploads: UploadService
113171
streaming: StreamingService
114172
test: ResponseTestService
115173
sse: SseService
174+
internal: InternalTestService
175+
custom: CustomPathService
116176
}
117177

118178
export type TestApplication = Application<TestServiceTypes>
@@ -131,6 +191,8 @@ export function getApp(): TestApplication {
131191
})
132192
app.use('test', new ResponseTestService())
133193
app.use('sse', new SseService())
194+
app.use('internal', new InternalTestService())
195+
app.use('custom', new CustomPathService())
134196

135197
return app
136198
}

packages/feathers/src/application.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,12 @@ export class Feathers<Services, Settings>
155155

156156
const {
157157
params: colonParams,
158-
data: { service, params: dataParams }
158+
data: { service, params: dataParams, method, httpMethod }
159159
} = result
160160

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

163-
return { service, params }
163+
return { service, params, method, httpMethod }
164164
}
165165

166166
protected _setup() {
@@ -277,6 +277,25 @@ export class Feathers<Services, Settings>
277277

278278
this.routes.insert(path, routerParams)
279279
this.routes.insert(`${path}/:__id`, routerParams)
280+
281+
// Register routes for custom method paths
282+
const { methodOptions } = serviceOptions
283+
for (const [methodName, methodConfig] of Object.entries(methodOptions)) {
284+
if (methodConfig.path && methodConfig.external !== false) {
285+
// Convert :id in path to :__id for consistency with standard routes
286+
const customPath = methodConfig.path.replace(/:id\b/g, ':__id')
287+
const fullPath = `${path}/${customPath}`
288+
289+
this.routes.insert(fullPath, {
290+
...routerParams,
291+
method: methodName,
292+
httpMethod: methodConfig.http || 'POST'
293+
})
294+
295+
debug(`Registered custom path \`${fullPath}\` for method \`${methodName}\``)
296+
}
297+
}
298+
280299
this.services[location] = protoService
281300

282301
// If we ran setup already, set this service up explicitly, this will not `await`

packages/feathers/src/client/fetch.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,4 +210,57 @@ describe('fetch REST connector', function () {
210210
})
211211

212212
clientTests(app, 'todos')
213+
214+
describe('custom method paths with client config', () => {
215+
// Create a client with method configuration
216+
const customConnection = fetchClient(fetch, {
217+
baseUrl,
218+
methods: {
219+
custom: {
220+
status: { args: ['id', 'params'], http: 'GET', path: ':id/status' },
221+
stats: { args: ['params'], http: 'GET', path: 'stats' },
222+
archive: { args: ['id', 'params'], http: 'POST', path: ':id/archive' },
223+
updateStatus: { args: ['id', 'data', 'params'], http: 'POST', path: ':id/update-status' }
224+
}
225+
}
226+
})
227+
const customApp = feathers<TestServiceTypes>().configure(customConnection)
228+
const customService = customApp.service('custom') as any
229+
230+
it('GET :id/status - client uses correct verb and path', async () => {
231+
const result = await customService.status('1', {})
232+
233+
expect(result.id).toBe('1')
234+
expect(result.status).toBeDefined()
235+
})
236+
237+
it('GET stats - client calls without id', async () => {
238+
const result = await customService.stats({})
239+
240+
expect(result).toHaveProperty('total')
241+
expect(result).toHaveProperty('active')
242+
})
243+
244+
it('POST :id/archive - client sends POST to path', async () => {
245+
const result = await customService.archive('1', {})
246+
247+
expect(result.id).toBe('1')
248+
})
249+
250+
it('POST :id/update-status - client sends id, data, params', async () => {
251+
const result = await customService.updateStatus('1', { status: 'updated-via-client' }, {})
252+
253+
expect(result.id).toBe('1')
254+
expect(result.status).toBe('updated-via-client')
255+
})
256+
257+
it('falls back to header-based method when no path config', async () => {
258+
// todoService doesn't have method config, so custom methods fall back
259+
const todoService = customApp.service('todos') as any
260+
const result = await todoService.customMethod({ text: 'Test' }, {})
261+
262+
expect(result.data.text).toBe('Test')
263+
expect(result.method).toBe('customMethod')
264+
})
265+
})
213266
})

packages/feathers/src/client/fetch.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { Params, Id, Query, NullableId } from '../declarations.js'
22
import { BadRequest, Unavailable, convert, errors } from '../errors.js'
33
import { _, stripSlashes } from '../commons.js'
44
import { protectedProperties } from '../service.js'
5+
import type { ClientMethodConfig, ClientMethodsConfig } from '../method.js'
6+
7+
// Re-export for backwards compatibility
8+
export type { ClientMethodConfig, ClientMethodsConfig }
59

610
function toError(error: Error & { code: string }, status?: number) {
711
if (error.code === 'ECONNREFUSED') {
@@ -21,6 +25,11 @@ interface FetchClientSettings {
2125
connection: typeof fetch
2226
stringify: (query: Query) => string
2327
events?: string[]
28+
/**
29+
* Method configuration for custom methods.
30+
* Allows the client to use correct HTTP verbs and paths.
31+
*/
32+
methods?: ClientMethodsConfig
2433
}
2534

2635
export type RequestOptions = Omit<RequestInit, 'body'> & { url: string; body?: unknown }
@@ -31,13 +40,15 @@ export class FetchClient<T = any, D = Partial<T>, P extends Params = FetchClient
3140
connection: typeof fetch
3241
stringify: (query: Query) => string
3342
events?: string[]
43+
methods?: ClientMethodsConfig
3444

3545
constructor(settings: FetchClientSettings) {
3646
this.name = stripSlashes(settings.name)
3747
this.connection = settings.connection
3848
this.base = `${settings.baseUrl}/${this.name}`
3949
this.stringify = settings.stringify
4050
this.events = settings.events
51+
this.methods = settings.methods
4152
}
4253

4354
makeUrl(query: Query, id?: string | number | null, route?: { [key: string]: string }) {
@@ -113,7 +124,43 @@ export class FetchClient<T = any, D = Partial<T>, P extends Params = FetchClient
113124
return response.json()
114125
}
115126

116-
callCustomMethod(method: string, body: unknown, params: FetchClientParams) {
127+
/**
128+
* Makes URL for a custom method with a path pattern.
129+
* Replaces :id with the id value and other placeholders with route params.
130+
*/
131+
makeCustomUrl(path: string, query: Query, id?: string | number | null, route?: { [key: string]: string }) {
132+
// Replace :id with the actual id value
133+
let resolvedPath = path
134+
if (id !== null && id !== undefined) {
135+
resolvedPath = resolvedPath.replace(/:id\b/, encodeURIComponent(String(id)))
136+
}
137+
138+
// Replace other route params
139+
if (route) {
140+
Object.keys(route).forEach((key) => {
141+
resolvedPath = resolvedPath.replace(`:${key}`, route[key])
142+
})
143+
}
144+
145+
const url = `${this.base}/${resolvedPath}`
146+
return url + this.getQuery(query || {})
147+
}
148+
149+
/**
150+
* Calls a custom service method.
151+
* If method config is available, uses the correct HTTP verb and path.
152+
* Otherwise falls back to POST with X-Service-Method header.
153+
*/
154+
callCustomMethod(method: string, ...args: any[]) {
155+
const methodConfig = this.methods?.[method]
156+
157+
if (methodConfig?.path) {
158+
// Use configured HTTP verb and path
159+
return this.callWithPath(method, methodConfig, args)
160+
}
161+
162+
// Fall back to header-based custom method (backwards compatible)
163+
const [body, params = {}] = args as [unknown, FetchClientParams]
117164
return this.request(
118165
{
119166
url: this.makeUrl(params?.query, null, params?.route),
@@ -127,6 +174,37 @@ export class FetchClient<T = any, D = Partial<T>, P extends Params = FetchClient
127174
)
128175
}
129176

177+
/**
178+
* Calls a custom method using the configured path and HTTP verb.
179+
*/
180+
protected callWithPath(_method: string, config: ClientMethodConfig, args: any[]) {
181+
const { http = 'POST', path, args: argNames = ['data', 'params'] } = config
182+
183+
// Build argument map
184+
const argMap: { id?: Id; data?: unknown; params?: FetchClientParams } = {}
185+
argNames.forEach((name, index) => {
186+
if (name === 'id') argMap.id = args[index]
187+
else if (name === 'data') argMap.data = args[index]
188+
else if (name === 'params') argMap.params = args[index] || {}
189+
})
190+
191+
const params = argMap.params || {}
192+
const url = this.makeCustomUrl(path!, params.query, argMap.id, params.route)
193+
194+
// Only include body for methods that support it
195+
const hasBody = ['POST', 'PUT', 'PATCH'].includes(http!)
196+
const body = hasBody ? argMap.data : undefined
197+
198+
return this.request(
199+
{
200+
url,
201+
method: http!,
202+
body
203+
},
204+
params
205+
)
206+
}
207+
130208
async *handleEventStream(res: Response) {
131209
const reader = res.body.getReader()
132210
const decoder = new TextDecoder()
@@ -302,8 +380,9 @@ export class ProxiedFetchClient<
302380
!prop.startsWith('Symbol(') &&
303381
!protectedProperties.includes(prop)
304382
) {
305-
return function (data: any, params?: P) {
306-
return target.callCustomMethod(prop, data, params)
383+
// Pass all arguments to callCustomMethod
384+
return function (...args: any[]) {
385+
return target.callCustomMethod(prop, ...args)
307386
}
308387
}
309388

packages/feathers/src/client/index.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,50 @@
11
import qs from 'qs'
22
import type { Application, Query } from '../declarations.js'
3-
import { FetchClient, ProxiedFetchClient } from './fetch.js'
3+
import { FetchClient, ProxiedFetchClient, ClientMethodsConfig } from './fetch.js'
44
import { sseClient, SseClientOptions } from './sse.js'
55
import { defaultServiceEvents } from '../service.js'
66

77
export * from './fetch.js'
88
export * from './types.js'
99
export * from './sse.js'
1010

11+
/**
12+
* Service method configuration for the client.
13+
* Maps service names to their method configurations.
14+
*/
15+
export type ServiceMethodsConfig = Record<string, ClientMethodsConfig>
16+
1117
export type ClientOptions = {
1218
baseUrl?: string
1319
Service?: typeof FetchClient
1420
stringify?: (query: Query) => string
1521
sse?: string | SseClientOptions
22+
/**
23+
* Method configuration for custom methods on each service.
24+
* Allows the client to use correct HTTP verbs and paths.
25+
*
26+
* @example
27+
* ```ts
28+
* const client = fetchClient(fetch, {
29+
* baseUrl: 'http://localhost:3030',
30+
* methods: {
31+
* messages: {
32+
* status: { args: ['id', 'params'], http: 'GET', path: ':id/status' },
33+
* archive: { args: ['id', 'params'], http: 'POST', path: ':id/archive' }
34+
* }
35+
* }
36+
* })
37+
* ```
38+
*/
39+
methods?: ServiceMethodsConfig
1640
}
1741

1842
export function fetchClient(connection: typeof fetch, options: ClientOptions = {}) {
19-
const { stringify = qs.stringify, baseUrl = '', Service = ProxiedFetchClient } = options
43+
const { stringify = qs.stringify, baseUrl = '', Service = ProxiedFetchClient, methods = {} } = options
2044
const events = options.sse ? defaultServiceEvents : undefined
2145
const sseOptions = typeof options.sse === 'string' ? { path: options.sse } : options.sse
2246
const defaultService = function (name: string) {
23-
return new Service({ baseUrl, name, connection, stringify, events })
47+
return new Service({ baseUrl, name, connection, stringify, events, methods: methods[name] })
2448
}
2549
const initialize = (_app: Application) => {
2650
const app = _app as Application & { rest: typeof fetch }

0 commit comments

Comments
 (0)