Skip to content

Commit e65abfb

Browse files
authored
feat(core): Public custom service methods (#2270)
1 parent 28114c4 commit e65abfb

27 files changed

Lines changed: 347 additions & 199 deletions

File tree

packages/express/src/declarations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export type Application<ServiceTypes = any, AppSettings = any> =
3333

3434
declare module '@feathersjs/feathers/lib/declarations' {
3535
export interface ServiceOptions {
36-
middleware: {
36+
middleware?: {
3737
before: express.RequestHandler[],
3838
after: express.RequestHandler[]
3939
}

packages/express/src/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,15 @@ export default function feathersExpress<S = any, C = any> (feathersApp?: Feather
3535
const mixin: any = {
3636
use (location: string, ...rest: any[]) {
3737
let service: any;
38-
const middleware = Array.from(arguments).slice(1)
39-
.reduce(function (middleware, arg) {
38+
let options = {};
39+
40+
const middleware = rest.reduce(function (middleware, arg) {
4041
if (typeof arg === 'function' || Array.isArray(arg)) {
4142
middleware[service ? 'after' : 'before'].push(arg);
4243
} else if (!service) {
4344
service = arg;
45+
} else if (arg.methods || arg.events) {
46+
options = arg;
4447
} else {
4548
throw new Error('Invalid options passed to app.use');
4649
}
@@ -62,7 +65,10 @@ export default function feathersExpress<S = any, C = any> (feathersApp?: Feather
6265

6366
debug('Registering service with middleware', middleware);
6467
// Since this is a service, call Feathers `.use`
65-
(feathersApp as FeathersApplication).use.call(this, location, service, { middleware });
68+
(feathersApp as FeathersApplication).use.call(this, location, service, {
69+
...options,
70+
middleware
71+
});
6672

6773
return this;
6874
},

packages/express/src/rest.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Debug from 'debug';
22
import { MethodNotAllowed } from '@feathersjs/errors';
33
import { HookContext } from '@feathersjs/hooks';
4-
import { createContext, getServiceOptions, NullableId, Params } from '@feathersjs/feathers';
4+
import { createContext, defaultServiceMethods, getServiceOptions, NullableId, Params } from '@feathersjs/feathers';
55
import { Request, Response, NextFunction, RequestHandler, Router } from 'express';
66

77
import { parseAuthentication } from './authentication';
@@ -94,14 +94,13 @@ export const serviceMiddleware = (callback: ServiceCallback) =>
9494
}
9595

9696
export const serviceMethodHandler = (
97-
service: any, methodName: string, getArgs: (opts: ServiceParams) => any[], header?: string
97+
service: any, methodName: string, getArgs: (opts: ServiceParams) => any[], headerOverride?: string
9898
) => serviceMiddleware(async (req, res, options) => {
99-
const method = (typeof header === 'string' && req.headers[header])
100-
? req.headers[header] as string
101-
: methodName
99+
const methodOverride = typeof headerOverride === 'string' && (req.headers[headerOverride] as string);
100+
const method = methodOverride ? methodOverride : methodName
102101
const { methods } = getServiceOptions(service);
103102

104-
if (!methods.includes(method)) {
103+
if (!methods.includes(method) || defaultServiceMethods.includes(methodOverride)) {
105104
res.status(statusCodes.methodNotAllowed);
106105

107106
throw new MethodNotAllowed(`Method \`${method}\` is not supported by this endpoint.`);

packages/express/test/authentication.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -174,19 +174,20 @@ describe('@feathersjs/express/authentication', () => {
174174
});
175175
});
176176

177-
it.skip('protected endpoint fails with invalid Authorization header', () => {
178-
return axios.get('/protected', {
179-
headers: {
180-
Authorization: 'Bearer: something wrong'
181-
}
182-
}).then(() => {
177+
it.skip('protected endpoint fails with invalid Authorization header', async () => {
178+
try {
179+
await axios.get('/protected', {
180+
headers: {
181+
Authorization: 'Bearer: something wrong'
182+
}
183+
});
183184
assert.fail('Should never get here');
184-
}).catch(error => {
185+
} catch (error) {
185186
const { data } = error.response;
186187

187188
assert.strictEqual(data.name, 'NotAuthenticated');
188189
assert.strictEqual(data.message, 'Not authenticated');
189-
});
190+
}
190191
});
191192

192193
it('can request protected endpoint with JWT present', () => {

packages/express/test/rest.test.ts

Lines changed: 75 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
/* eslint-disable @typescript-eslint/no-unused-vars */
22
import { strict as assert } from 'assert';
3-
import axios from 'axios';
3+
import axios, { AxiosRequestConfig } from 'axios';
44

55
import { Server } from 'http';
6+
import { Request, Response, NextFunction } from 'express';
67
import { feathers, HookContext, Id, Params } from '@feathersjs/feathers';
7-
import { Service, testRest } from '@feathersjs/tests';
8+
import { Service, restTests } from '@feathersjs/tests';
9+
import { BadRequest } from '@feathersjs/errors';
810

911
import * as express from '../src'
10-
import { Request, Response, NextFunction } from 'express';
11-
import { BadRequest } from '@feathersjs/errors/lib';
1212

1313
const expressify = express.default;
1414
const { rest } = express;
15+
const errorHandler = express.errorHandler({
16+
logger: false
17+
});
1518

1619
describe('@feathersjs/express/rest provider', () => {
1720
describe('base functionality', () => {
@@ -99,9 +102,9 @@ describe('@feathersjs/express/rest provider', () => {
99102

100103
after(done => server.close(done));
101104

102-
testRest('Services', 'todo', 4777);
103-
testRest('Root Service', '/', 4777);
104-
testRest('Dynamic Services', 'tasks', 4777);
105+
restTests('Services', 'todo', 4777);
106+
restTests('Root Service', '/', 4777);
107+
restTests('Dynamic Services', 'tasks', 4777);
105108

106109
describe('res.hook', () => {
107110
const convertHook = (hook: HookContext) => {
@@ -526,7 +529,7 @@ describe('@feathersjs/express/rest provider', () => {
526529
};
527530
}
528531
})
529-
.use(express.errorHandler());
532+
.use(errorHandler);
530533

531534
server = await app.listen(6880);
532535
});
@@ -566,62 +569,68 @@ describe('@feathersjs/express/rest provider', () => {
566569
});
567570
});
568571

569-
// describe('Custom methods', () => {
570-
// let server: Server;
571-
// let app: express.Application;
572-
573-
// before(async () => {
574-
// app = expressify(feathers())
575-
// .configure(rest())
576-
// .use(express.json())
577-
// .use('/todo', {
578-
// async get (id) {
579-
// return id;
580-
// },
581-
// // httpMethod is usable as a decorator: @httpMethod('POST', '/:__feathersId/custom-path')
582-
// custom: rest.httpMethod('POST')((feathers as any).activateHooks(['id', 'data', 'params'])(
583-
// (id: any, data: any) => {
584-
// return Promise.resolve({
585-
// id,
586-
// data
587-
// });
588-
// }
589-
// )),
590-
// other: rest.httpMethod('PATCH', ':__feathersId/second-method')(
591-
// (feathers as any).activateHooks(['id', 'data', 'params'])(
592-
// (id: any, data: any) => {
593-
// return Promise.resolve({
594-
// id,
595-
// data
596-
// });
597-
// }
598-
// )
599-
// )
600-
// });
601-
602-
// server = await app.listen(4781);
603-
// });
604-
605-
// after(done => server.close(done));
606-
607-
// it('works with custom methods', async () => {
608-
// const res = await axios.post('http://localhost:4781/todo/42/custom', { text: 'Do dishes' });
609-
610-
// assert.equal(res.headers.allow, 'GET,POST,PATCH');
611-
// assert.deepEqual(res.data, {
612-
// id: '42',
613-
// data: { text: 'Do dishes' }
614-
// });
615-
// });
616-
617-
// it('works with custom methods - with route', async () => {
618-
// const res = await axios.patch('http://localhost:4781/todo/12/second-method', { text: 'Hmm' });
619-
620-
// assert.equal(res.headers.allow, 'GET,POST,PATCH');
621-
// assert.deepEqual(res.data, {
622-
// id: '12',
623-
// data: { text: 'Hmm' }
624-
// });
625-
// });
626-
// });
572+
describe('Custom methods', () => {
573+
let server: Server;
574+
let app: express.Application;
575+
576+
before(async () => {
577+
app = expressify(feathers())
578+
.configure(rest())
579+
.use(express.json())
580+
.use('/todo', new Service(), {
581+
methods: ['find', 'customMethod']
582+
})
583+
.use(errorHandler);
584+
585+
server = await app.listen(4781);
586+
});
587+
588+
after(done => server.close(done));
589+
590+
it('calls .customMethod with X-Service-Method header', async () => {
591+
const payload = { text: 'Do dishes' };
592+
const res = await axios.post('http://localhost:4781/todo', payload, {
593+
headers: {
594+
'X-Service-Method': 'customMethod'
595+
}
596+
});
597+
598+
assert.deepEqual(res.data, {
599+
data: payload,
600+
method: 'customMethod',
601+
provider: 'rest'
602+
});
603+
});
604+
605+
it('throws MethodNotImplement for .setup, non option and default methods', async () => {
606+
const options: AxiosRequestConfig = {
607+
method: 'POST',
608+
url: 'http://localhost:4781/todo',
609+
data: { text: 'Do dishes' }
610+
};
611+
const testMethod = (name: string) => {
612+
return assert.rejects(() => axios({
613+
...options,
614+
headers: {
615+
'X-Service-Method': name
616+
}
617+
}), (error: any) => {
618+
assert.deepEqual(error.response.data, {
619+
name: 'MethodNotAllowed',
620+
message: `Method \`${name}\` is not supported by this endpoint.`,
621+
code: 405,
622+
className: 'method-not-allowed'
623+
});
624+
625+
return true;
626+
});
627+
}
628+
629+
await testMethod('setup');
630+
await testMethod('internalMethod');
631+
await testMethod('nonExisting');
632+
await testMethod('create');
633+
await testMethod('find');
634+
});
635+
});
627636
});

packages/feathers/src/declarations.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ export interface ServiceHookOverloads<S> {
100100
export type FeathersService<A = FeathersApplication, S = Service<any>> =
101101
S & ServiceAddons<A, S> & OptionalPick<ServiceHookOverloads<S>, keyof S>;
102102

103+
export type CustomMethod<Methods extends string> = {
104+
[k in Methods]: <X = any> (data: any, params?: Params) => Promise<X>;
105+
}
106+
103107
export type ServiceMixin<A> = (service: FeathersService<A>, path: string, options?: ServiceOptions) => void;
104108

105109
export type ServiceGenericType<S> = S extends ServiceInterface<infer T> ? T : any;

packages/feathers/src/service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const defaultServiceArguments = {
1313
remove: [ 'id', 'params' ]
1414
}
1515

16-
export const defaultServiceMethods = Object.keys(defaultServiceArguments).concat('setup');
16+
export const defaultServiceMethods = Object.keys(defaultServiceArguments);
1717

1818
export const defaultEventMap = {
1919
create: 'created',
@@ -60,7 +60,7 @@ export function wrapService (
6060
const protoService = Object.create(service);
6161
const serviceOptions = getServiceOptions(service, options);
6262

63-
if (Object.keys(serviceOptions.methods).length === 0) {
63+
if (Object.keys(serviceOptions.methods).length === 0 && typeof service.setup !== 'function') {
6464
throw new Error(`Invalid service object passed for path \`${location}\``);
6565
}
6666

packages/rest-client/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
"@types/mocha": "^8.2.2",
6565
"@types/node": "^14.14.35",
6666
"axios": "^0.21.1",
67-
"body-parser": "^1.19.0",
6867
"mocha": "^8.3.2",
6968
"node-fetch": "^2.6.1",
7069
"rxjs": "^6.6.6",

packages/rest-client/src/base.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import qs from 'qs';
2-
import { Unavailable } from '@feathersjs/errors';
3-
import { _ } from '@feathersjs/commons';
4-
import { stripSlashes } from '@feathersjs/commons';
5-
import { convert } from '@feathersjs/errors';
6-
import { Params, Id, Query, NullableId } from '@feathersjs/feathers';
2+
import { Params, Id, Query, NullableId, ServiceInterface } from '@feathersjs/feathers';
3+
import { Unavailable, convert } from '@feathersjs/errors';
4+
import { _, stripSlashes } from '@feathersjs/commons';
75

86
function toError (error: Error & { code: string }) {
97
if (error.code === 'ECONNREFUSED') {
@@ -20,7 +18,7 @@ interface RestClientSettings {
2018
options: any;
2119
}
2220

23-
export abstract class Base {
21+
export abstract class Base<T = any, D = Partial<T>> implements ServiceInterface<T, D> {
2422
name: string;
2523
base: string;
2624
connection: any;
@@ -34,9 +32,10 @@ export abstract class Base {
3432
}
3533

3634
makeUrl (query: Query, id?: string|number|null) {
37-
query = query || {};
3835
let url = this.base;
3936

37+
query = query || {};
38+
4039
if (typeof id !== 'undefined' && id !== null) {
4140
url += `/${encodeURIComponent(id)}`;
4241
}
@@ -56,6 +55,24 @@ export abstract class Base {
5655

5756
abstract request (options: any, params: Params): any;
5857

58+
methods (this: any, ...names: string[]) {
59+
names.forEach(method => {
60+
this[method] = function (body: any, params: Params = {}) {
61+
return this.request({
62+
body,
63+
url: this.makeUrl(params.query),
64+
method: 'POST',
65+
headers: Object.assign({
66+
'Content-Type': 'application/json',
67+
'X-Service-Method': method
68+
}, params.headers)
69+
}, params).catch(toError);
70+
}
71+
});
72+
73+
return this;
74+
}
75+
5976
find (params: Params = {}) {
6077
return this.request({
6178
url: this.makeUrl(params.query),
@@ -76,7 +93,7 @@ export abstract class Base {
7693
}, params).catch(toError);
7794
}
7895

79-
create (body: any, params: Params = {}) {
96+
create (body: D, params: Params = {}) {
8097
return this.request({
8198
url: this.makeUrl(params.query),
8299
body,
@@ -85,7 +102,7 @@ export abstract class Base {
85102
}, params).catch(toError);
86103
}
87104

88-
update (id: NullableId, body: any, params: Params = {}) {
105+
update (id: NullableId, body: D, params: Params = {}) {
89106
if (typeof id === 'undefined') {
90107
return Promise.reject(new Error('id for \'update\' can not be undefined, only \'null\' when updating multiple entries'));
91108
}
@@ -98,7 +115,7 @@ export abstract class Base {
98115
}, params).catch(toError);
99116
}
100117

101-
patch (id: NullableId, body: any, params: Params = {}) {
118+
patch (id: NullableId, body: D, params: Params = {}) {
102119
if (typeof id === 'undefined') {
103120
return Promise.reject(new Error('id for \'patch\' can not be undefined, only \'null\' when updating multiple entries'));
104121
}

0 commit comments

Comments
 (0)