Skip to content

Commit 7d15bfe

Browse files
committed
fix(rest): Tidy up rest decorator metadata
Simplify method level metadata processing Add support for basePath
1 parent 842a191 commit 7d15bfe

File tree

4 files changed

+192
-98
lines changed

4 files changed

+192
-98
lines changed

packages/rest/src/router/metadata.ts

Lines changed: 115 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import {
1616

1717
const debug = require('debug')('loopback:core:router:metadata');
1818

19+
const ENDPOINTS_KEY = 'rest:endpoints';
20+
const API_SPEC_KEY = 'rest:api-spec';
21+
1922
// tslint:disable:no-any
2023

2124
export interface ControllerSpec {
@@ -36,6 +39,14 @@ export interface ControllerSpec {
3639
* Decorate the given Controller constructor with metadata describing
3740
* the HTTP/REST API the Controller implements/provides.
3841
*
42+
* `@api` can be applied to controller classes. For example,
43+
* ```
44+
* @api({basePath: '/my'})
45+
* class MyController {
46+
* // ...
47+
* }
48+
* ```
49+
*
3950
* @param spec OpenAPI specification describing the endpoints
4051
* handled by this controller
4152
*
@@ -47,95 +58,89 @@ export function api(spec: ControllerSpec) {
4758
typeof constructor === 'function',
4859
'The @api decorator can be applied to constructors only.',
4960
);
50-
Reflector.defineMetadata('loopback:api-spec', spec, constructor);
61+
const apiSpec = resolveControllerSpec(constructor, spec);
62+
Reflector.defineMetadata(API_SPEC_KEY, apiSpec, constructor);
5163
};
5264
}
5365

66+
/**
67+
* Data structure for REST related metadata
68+
*/
5469
interface RestEndpoint {
5570
verb: string;
5671
path: string;
72+
spec?: OperationObject;
73+
target: any;
5774
}
5875

59-
export function getControllerSpec(constructor: Function): ControllerSpec {
76+
/**
77+
* Build the api spec from class and method level decorations
78+
* @param constructor Controller class
79+
* @param spec API spec
80+
*/
81+
function resolveControllerSpec(
82+
constructor: Function,
83+
spec?: ControllerSpec,
84+
): ControllerSpec {
6085
debug(`Retrieving OpenAPI specification for controller ${constructor.name}`);
6186

62-
let spec: ControllerSpec = Reflector.getMetadata(
63-
'loopback:api-spec',
64-
constructor,
65-
);
66-
6787
if (spec) {
6888
debug(' using class-level spec defined via @api()', spec);
69-
return spec;
89+
spec = Object.assign({}, spec);
90+
} else {
91+
spec = {paths: {}};
7092
}
7193

72-
spec = {paths: {}};
73-
for (
74-
let proto = constructor.prototype;
75-
proto && proto !== Object.prototype;
76-
proto = Object.getPrototypeOf(proto)
77-
) {
78-
addPrototypeMethodsToSpec(spec, proto);
79-
}
80-
return spec;
81-
}
82-
83-
function addPrototypeMethodsToSpec(spec: ControllerSpec, proto: any) {
84-
const controllerMethods = Object.getOwnPropertyNames(proto).filter(
85-
key => key !== 'constructor' && typeof proto[key] === 'function',
86-
);
87-
for (const methodName of controllerMethods) {
88-
addControllerMethodToSpec(spec, proto, methodName);
89-
}
90-
}
94+
const endpoints =
95+
Reflector.getMetadata(ENDPOINTS_KEY, constructor.prototype) || {};
96+
97+
for (const op in endpoints) {
98+
const endpoint = endpoints[op];
99+
const className =
100+
endpoint.target.constructor.name ||
101+
constructor.name ||
102+
'<AnonymousClass>';
103+
const fullMethodName = `${className}.${op}`;
104+
105+
const {verb, path} = endpoint;
106+
const endpointName = `${fullMethodName} (${verb} ${path})`;
107+
108+
let operationSpec = endpoint.spec;
109+
if (!operationSpec) {
110+
// The operation was defined via @operation(verb, path) with no spec
111+
operationSpec = {
112+
responses: {},
113+
};
114+
}
91115

92-
function addControllerMethodToSpec(
93-
spec: ControllerSpec,
94-
proto: any,
95-
methodName: string,
96-
) {
97-
const className = proto.constructor.name || '<UnknownClass>';
98-
const fullMethodName = `${className}.${methodName}`;
99-
100-
const endpoint: RestEndpoint = Reflector.getMetadata(
101-
'loopback:operation-endpoint',
102-
proto,
103-
methodName,
104-
);
105-
106-
if (!endpoint) {
107-
debug(` skipping ${fullMethodName} - no endpoint is defined`);
108-
return;
109-
}
116+
if (!spec.paths[path]) {
117+
spec.paths[path] = {};
118+
}
110119

111-
const {verb, path} = endpoint;
112-
const endpointName = `${fullMethodName} (${verb} ${path})`;
113-
114-
let operationSpec = Reflector.getMetadata(
115-
'loopback:operation-spec',
116-
proto,
117-
methodName,
118-
);
119-
if (!operationSpec) {
120-
// The operation was defined via @operation(verb, path) with no spec
121-
operationSpec = {
122-
responses: {},
123-
};
124-
}
120+
if (spec.paths[path][verb]) {
121+
// Operations from subclasses override those from the base
122+
debug(` Overriding ${endpointName} - endpoint was already defined`);
123+
}
125124

126-
if (!spec.paths[path]) {
127-
spec.paths[path] = {};
125+
debug(` adding ${endpointName}`, operationSpec);
126+
spec.paths[path][verb] = Object.assign({}, operationSpec, {
127+
'x-operation-name': op,
128+
});
128129
}
130+
return spec;
131+
}
129132

130-
if (spec.paths[path][verb]) {
131-
debug(` skipping ${endpointName} - endpoint was already defined`);
132-
return;
133+
/**
134+
* Get the controller spec for the given class
135+
* @param constructor Controller class
136+
*/
137+
export function getControllerSpec(constructor: Function): ControllerSpec {
138+
let spec = Reflector.getMetadata(API_SPEC_KEY, constructor);
139+
if (!spec) {
140+
spec = resolveControllerSpec(constructor, spec);
141+
Reflector.defineMetadata(API_SPEC_KEY, spec, constructor);
133142
}
134-
135-
debug(` adding ${endpointName}`, operationSpec);
136-
spec.paths[path][verb] = Object.assign({}, operationSpec, {
137-
'x-operation-name': methodName,
138-
});
143+
return spec;
139144
}
140145

141146
/**
@@ -207,20 +212,35 @@ export function del(path: string, spec?: OperationObject) {
207212
* of this operation.
208213
*/
209214
export function operation(verb: string, path: string, spec?: OperationObject) {
210-
// tslint:disable-next-line:no-any
211215
return function(
212-
target: object,
216+
target: any,
213217
propertyKey: string,
214218
descriptor: PropertyDescriptor,
215219
) {
216-
const endpoint: RestEndpoint = {verb, path};
217-
Reflector.defineMetadata(
218-
'loopback:operation-endpoint',
219-
endpoint,
220-
target,
221-
propertyKey,
220+
assert(
221+
typeof target[propertyKey] === 'function',
222+
'@operation decorator can be applied to methods only',
222223
);
223224

225+
let endpoints = Object.assign(
226+
{},
227+
Reflector.getMetadata(ENDPOINTS_KEY, target),
228+
);
229+
Reflector.defineMetadata(ENDPOINTS_KEY, endpoints, target);
230+
231+
let endpoint: Partial<RestEndpoint> = endpoints[propertyKey];
232+
if (!endpoint) {
233+
// Add the new endpoint metadata for the method
234+
endpoint = {verb, path, spec, target};
235+
endpoints[propertyKey] = endpoint;
236+
} else {
237+
// Update the endpoint metadata
238+
// It can be created by @param
239+
endpoint.verb = verb;
240+
endpoint.path = path;
241+
endpoint.target = target;
242+
}
243+
224244
if (!spec) {
225245
// Users can define parameters and responses using decorators
226246
return;
@@ -231,7 +251,7 @@ export function operation(verb: string, path: string, spec?: OperationObject) {
231251
// will invoke param() decorator first and operation() second.
232252
// As a result, we need to preserve any partial definitions
233253
// already provided by other decorators.
234-
editOperationSpec(target, propertyKey, overrides => {
254+
editOperationSpec(endpoint, overrides => {
235255
const mergedSpec = Object.assign({}, spec, overrides);
236256

237257
// Merge "responses" definitions
@@ -285,7 +305,20 @@ export function param(paramSpec: ParameterObject) {
285305
'@param decorator can be applied to methods only',
286306
);
287307

288-
editOperationSpec(target, propertyKey, operationSpec => {
308+
let endpoints = Object.assign(
309+
{},
310+
Reflector.getMetadata(ENDPOINTS_KEY, target),
311+
);
312+
Reflector.defineMetadata(ENDPOINTS_KEY, endpoints, target);
313+
314+
let endpoint: Partial<RestEndpoint> = endpoints[propertyKey];
315+
if (!endpoint) {
316+
// Add the new endpoint metadata for the method
317+
endpoint = {target};
318+
endpoints[propertyKey] = endpoint;
319+
}
320+
321+
editOperationSpec(endpoint, operationSpec => {
289322
let decoratorStyle;
290323
if (typeof descriptorOrParameterIndex === 'number') {
291324
decoratorStyle = 'parameter';
@@ -318,30 +351,18 @@ export function param(paramSpec: ParameterObject) {
318351
}
319352

320353
function editOperationSpec(
321-
target: any,
322-
propertyKey: string,
354+
endpoint: Partial<RestEndpoint>,
323355
updateFn: (spec: OperationObject) => OperationObject,
324356
) {
325-
let spec: OperationObject = Reflector.getMetadata(
326-
'loopback:operation-spec',
327-
target,
328-
propertyKey,
329-
);
330-
357+
let spec = endpoint.spec;
331358
if (!spec) {
332359
spec = {
333360
responses: {},
334361
};
335362
}
336363

337364
spec = updateFn(spec);
338-
339-
Reflector.defineMetadata(
340-
'loopback:operation-spec',
341-
spec,
342-
target,
343-
propertyKey,
344-
);
365+
endpoint.spec = spec;
345366
}
346367

347368
export namespace param {

packages/rest/src/router/routing-table.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,26 @@ export class RoutingTable {
6666

6767
debug('Registering Controller with API', spec);
6868

69-
for (const path in spec.paths) {
70-
for (const verb in spec.paths[path]) {
71-
const opSpec: OperationObject = spec.paths[path][verb];
72-
const route = new ControllerRoute(verb, path, opSpec, controller);
69+
const basePath = spec.basePath || '/';
70+
for (const p in spec.paths) {
71+
for (const verb in spec.paths[p]) {
72+
const opSpec: OperationObject = spec.paths[p][verb];
73+
const fullPath = RoutingTable.joinPath(basePath, p);
74+
const route = new ControllerRoute(verb, fullPath, opSpec, controller);
7375
this.registerRoute(route);
7476
}
7577
}
7678
}
7779

80+
static joinPath(basePath: string, path: string) {
81+
const fullPath = [basePath, path]
82+
.join('/') // Join by /
83+
.replace(/(\/){2,}/g, '/') // Remove extra /
84+
.replace(/\/$/, '') // Remove trailing /
85+
.replace(/^(\/)?/, '/'); // Add leading /
86+
return fullPath;
87+
}
88+
7889
registerRoute(route: RouteEntry) {
7990
// TODO(bajtos) handle the case where opSpec.parameters contains $ref
8091
// See https://github.com/strongloop/loopback-next/issues/435

packages/rest/test/acceptance/routing/routing.acceptance.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,24 @@ describe('Routing', () => {
344344
await client.get('/greet?name=world').expect(200, 'hello world');
345345
});
346346

347+
it('supports controller routes declared via API spec with basePath', async () => {
348+
const app = givenAnApplication();
349+
const server = await givenAServer(app);
350+
351+
@api({basePath: '/my', paths: {}})
352+
class MyController {
353+
@get('/greet')
354+
greet(@param.query.string('name') name: string) {
355+
return `hello ${name}`;
356+
}
357+
}
358+
359+
app.controller(MyController);
360+
361+
const client = whenIMakeRequestTo(server);
362+
await client.get('/my/greet?name=world').expect(200, 'hello world');
363+
});
364+
347365
it('reports operations bound to unknown controller', async () => {
348366
const app = givenAnApplication();
349367
const server = await givenAServer(app);

0 commit comments

Comments
 (0)