From 5b19e21e3d2cda6eccee4e55306e698b8717ab8b Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 12 Dec 2017 23:48:43 -0800 Subject: [PATCH] feat: Refactor REST decorators to use factories --- packages/metadata/src/decorator-factory.ts | 92 ++++++-- packages/metadata/src/inspector.ts | 40 +++- packages/rest/src/router/metadata.ts | 256 ++++++++------------- 3 files changed, 209 insertions(+), 179 deletions(-) diff --git a/packages/metadata/src/decorator-factory.ts b/packages/metadata/src/decorator-factory.ts index 0a97b0bdc156..5eeb883c4604 100644 --- a/packages/metadata/src/decorator-factory.ts +++ b/packages/metadata/src/decorator-factory.ts @@ -10,17 +10,6 @@ const debug = debugModule('loopback:metadata:decorator'); // tslint:disable:no-any -function cloneDeep(val: T): T { - if (val === undefined) { - return {} as T; - } - return _.cloneDeepWith(val, v => { - // Do not clone functions - if (typeof v === 'function') return v; - return undefined; - }); -} - /** * An object mapping keys to corresponding metadata */ @@ -197,6 +186,8 @@ export class DecoratorFactory< if (typeof spec === 'object' && spec != null) { const specWithTarget = spec as any; return specWithTarget[DecoratorFactory.TARGET]; + } else { + return undefined; } } @@ -256,7 +247,9 @@ export class DecoratorFactory< if (meta == null && this.allowInheritance()) { // Clone the base metadata so that it won't be accidentally // mutated by sub classes - meta = cloneDeep(Reflector.getMetadata(this.key, target)); + meta = DecoratorFactory.cloneDeep( + Reflector.getMetadata(this.key, target), + ); meta = this.processInherited(meta, target, member, descriptorOrIndex); if (debug.enabled) { debug('%s: %j', targetName, meta); @@ -285,6 +278,17 @@ export class DecoratorFactory< const inst = new this(key, spec, options); return inst.create(); } + + static cloneDeep(val: T): T { + if (val === undefined) { + return {} as T; + } + return _.cloneDeepWith(val, v => { + // Do not clone functions + if (typeof v === 'function') return v; + return undefined; + }); + } } /** @@ -422,7 +426,8 @@ export class MethodDecoratorFactory extends DecoratorFactory< methodDescriptor?: TypedPropertyDescriptor | number, ) { if (localMeta == null) localMeta = {}; - if (localMeta[methodName!] != null) { + const methodMeta = localMeta[methodName!]; + if (this.getTarget(methodMeta) === target) { throw new Error( 'Decorator cannot be applied more than once on ' + this.getTargetName(target, methodName, methodDescriptor), @@ -542,3 +547,64 @@ export class ParameterDecoratorFactory extends DecoratorFactory< ); } } + +/** + * Factory for method level parameter decorator. For example, the following + * code uses `@param` to declare two parameters for `greet()`. + * ```ts + * class MyController { + * @param('name') // Parameter 0 + * @param('msg') // Parameter 1 + * greet() {} + * } + * ``` + */ +export class MethodParameterDecoratorFactory extends DecoratorFactory< + T, + MetadataMap, + MethodDecorator +> { + protected processInherited( + baseMeta: MetadataMap, + target: Object, + methodName?: string | symbol, + methodDescriptor?: TypedPropertyDescriptor | number, + ) { + return {[methodName!]: [this.spec]}; + } + + protected processLocal( + localMeta: MetadataMap, + target: Object, + methodName?: string | symbol, + methodDescriptor?: TypedPropertyDescriptor | number, + ) { + if (localMeta == null) localMeta = {}; + let params = localMeta[methodName!]; + params = [this.spec].concat(params); + localMeta[methodName!] = params; + return localMeta; + } + + create(): MethodDecorator { + return ( + target: Object, + methodName: string | symbol, + descriptor: TypedPropertyDescriptor, + ) => this.decorate(target, methodName, descriptor); + } + + /** + * Create a method decorator function + * @param key Metadata key + * @param spec Metadata object from the decorator function + * @param options Options for the decorator + */ + static createDecorator(key: string, spec: T, options?: DecoratorOptions) { + return super._createDecorator, MethodDecorator>( + key, + spec, + options, + ); + } +} diff --git a/packages/metadata/src/inspector.ts b/packages/metadata/src/inspector.ts index 192cd640e861..f96d51a6a85b 100644 --- a/packages/metadata/src/inspector.ts +++ b/packages/metadata/src/inspector.ts @@ -26,8 +26,14 @@ export class MetadataInspector { * @param key Metadata key * @param target Class that contains the metadata */ - static getClassMetadata(key: string, target: Function): T | undefined { - return Reflector.getMetadata(key, target); + static getClassMetadata( + key: string, + target: Function, + ownOnly?: boolean, + ): T | undefined { + return ownOnly + ? Reflector.getOwnMetadata(key, target) + : Reflector.getMetadata(key, target); } /** @@ -39,8 +45,11 @@ export class MetadataInspector { static getAllMethodMetadata( key: string, target: Object, + ownOnly?: boolean, ): MetadataMap | undefined { - return Reflector.getMetadata(key, target); + return ownOnly + ? Reflector.getOwnMetadata(key, target) + : Reflector.getMetadata(key, target); } /** @@ -55,9 +64,12 @@ export class MetadataInspector { key: string, target: Object, methodName?: string | symbol, + ownOnly?: boolean, ): T | undefined { methodName = methodName || ''; - const meta: MetadataMap = Reflector.getMetadata(key, target); + const meta: MetadataMap = ownOnly + ? Reflector.getOwnMetadata(key, target) + : Reflector.getMetadata(key, target); return meta && meta[methodName]; } @@ -70,8 +82,11 @@ export class MetadataInspector { static getAllPropertyMetadata( key: string, target: Object, + ownOnly?: boolean, ): MetadataMap | undefined { - return Reflector.getMetadata(key, target); + return ownOnly + ? Reflector.getOwnMetadata(key, target) + : Reflector.getMetadata(key, target); } /** @@ -86,8 +101,11 @@ export class MetadataInspector { key: string, target: Object, propertyName: string | symbol, + ownOnly?: boolean, ): T | undefined { - const meta: MetadataMap = Reflector.getMetadata(key, target); + const meta: MetadataMap = ownOnly + ? Reflector.getOwnMetadata(key, target) + : Reflector.getMetadata(key, target); return meta && meta[propertyName]; } @@ -103,9 +121,12 @@ export class MetadataInspector { key: string, target: Object, methodName?: string | symbol, + ownOnly?: boolean, ): T[] | undefined { methodName = methodName || ''; - const meta: MetadataMap = Reflector.getMetadata(key, target); + const meta: MetadataMap = ownOnly + ? Reflector.getOwnMetadata(key, target) + : Reflector.getMetadata(key, target); return meta && meta[methodName]; } @@ -123,9 +144,12 @@ export class MetadataInspector { target: Object, methodName: string | symbol, index: number, + ownOnly?: boolean, ): T | undefined { methodName = methodName || ''; - const meta: MetadataMap = Reflector.getMetadata(key, target); + const meta: MetadataMap = ownOnly + ? Reflector.getOwnMetadata(key, target) + : Reflector.getMetadata(key, target); const params = meta && meta[methodName]; return params && params[index]; } diff --git a/packages/rest/src/router/metadata.ts b/packages/rest/src/router/metadata.ts index 912afbda4502..abfb844d40b1 100644 --- a/packages/rest/src/router/metadata.ts +++ b/packages/rest/src/router/metadata.ts @@ -6,7 +6,20 @@ import * as assert from 'assert'; import * as _ from 'lodash'; -import {Reflector} from '@loopback/context'; +import { + Reflector, + MetadataInspector, + ClassDecoratorFactory, + MethodDecoratorFactory, + ParameterDecoratorFactory, + MetadataMap, + DecoratorFactory, + DecoratorOptions, + MethodParameterDecoratorFactory, +} from '@loopback/context'; + +import {ControllerClass} from '@loopback/core'; + import { OperationObject, ParameterLocation, @@ -16,24 +29,16 @@ import { PathsObject, } from '@loopback/openapi-spec'; -const debug = require('debug')('loopback:core:router:metadata'); +const debug = require('debug')('loopback:rest:router:metadata'); -const ENDPOINTS_KEY = 'rest:endpoints'; -const API_SPEC_KEY = 'rest:api-spec'; +const REST_METHODS_KEY = 'rest:methods'; +const REST_METHODS_WITH_PARAMETERS_KEY = 'rest:methods:parameters'; +const REST_PARAMETERS_KEY = 'rest:parameters'; +const REST_CLASS_KEY = 'rest:class'; +const REST_API_SPEC_KEY = 'rest:api-spec'; // tslint:disable:no-any -function cloneDeep(val: T): T { - if (val === undefined) { - return {} as T; - } - return _.cloneDeepWith(val, v => { - // Do not clone functions - if (typeof v === 'function') return v; - return undefined; - }); -} - export interface ControllerSpec { /** * The base path on which the Controller API is served. @@ -66,14 +71,10 @@ export interface ControllerSpec { * @decorator */ export function api(spec: ControllerSpec) { - return function(constructor: Function) { - assert( - typeof constructor === 'function', - 'The @api decorator can be applied to constructors only.', - ); - const apiSpec = resolveControllerSpec(constructor, spec); - Reflector.defineMetadata(API_SPEC_KEY, apiSpec, constructor); - }; + return ClassDecoratorFactory.createDecorator( + REST_CLASS_KEY, + spec, + ); } /** @@ -83,54 +84,43 @@ interface RestEndpoint { verb: string; path: string; spec?: OperationObject; - target: any; -} - -function getEndpoints( - target: any, -): {[property: string]: Partial} { - let endpoints = Reflector.getOwnMetadata(ENDPOINTS_KEY, target); - if (!endpoints) { - // Clone the endpoints so that subclasses won't mutate the metadata - // in the base class - const baseEndpoints = Reflector.getMetadata(ENDPOINTS_KEY, target); - endpoints = cloneDeep(baseEndpoints); - Reflector.defineMetadata(ENDPOINTS_KEY, endpoints, target); - } - return endpoints; } /** * Build the api spec from class and method level decorations * @param constructor Controller class - * @param spec API spec */ -function resolveControllerSpec( - constructor: Function, - spec?: ControllerSpec, -): ControllerSpec { +function resolveControllerSpec(constructor: Function): ControllerSpec { debug(`Retrieving OpenAPI specification for controller ${constructor.name}`); + let spec = MetadataInspector.getClassMetadata( + REST_CLASS_KEY, + constructor, + ); if (spec) { debug(' using class-level spec defined via @api()', spec); - spec = cloneDeep(spec); + spec = DecoratorFactory.cloneDeep(spec); } else { spec = {paths: {}}; } - const endpoints = getEndpoints(constructor.prototype); + let endpoints = + MetadataInspector.getAllMethodMetadata( + REST_METHODS_KEY, + constructor.prototype, + ) || {}; + endpoints = DecoratorFactory.cloneDeep(endpoints); for (const op in endpoints) { + debug(' processing method %s', op); + const endpoint = endpoints[op]; const verb = endpoint.verb!; const path = endpoint.path!; let endpointName = ''; if (debug.enabled) { - const className = - endpoint.target.constructor.name || - constructor.name || - ''; + const className = constructor.name || ''; const fullMethodName = `${className}.${op}`; endpointName = `${fullMethodName} (${verb} ${path})`; } @@ -143,7 +133,26 @@ function resolveControllerSpec( }; endpoint.spec = operationSpec; } + debug(' operation for method %s: %j', op, endpoint); + debug(' processing parameters for method %s', op); + let params = MetadataInspector.getAllParameterMetadata( + REST_PARAMETERS_KEY, + constructor.prototype, + op, + ); + if (params == null) { + params = MetadataInspector.getMethodMetadata( + REST_METHODS_WITH_PARAMETERS_KEY, + constructor.prototype, + op, + ); + } + debug(' parameters for method %s: %j', op, params); + if (params != null) { + params = DecoratorFactory.cloneDeep(params); + operationSpec.parameters = params; + } operationSpec['x-operation-name'] = op; if (!spec.paths[path]) { @@ -166,10 +175,10 @@ function resolveControllerSpec( * @param constructor Controller class */ export function getControllerSpec(constructor: Function): ControllerSpec { - let spec = Reflector.getOwnMetadata(API_SPEC_KEY, constructor); + let spec = Reflector.getOwnMetadata(REST_API_SPEC_KEY, constructor); if (!spec) { - spec = resolveControllerSpec(constructor, spec); - Reflector.defineMetadata(API_SPEC_KEY, spec, constructor); + spec = resolveControllerSpec(constructor); + Reflector.defineMetadata(REST_API_SPEC_KEY, spec, constructor); } return spec; } @@ -243,53 +252,14 @@ export function del(path: string, spec?: OperationObject) { * of this operation. */ export function operation(verb: string, path: string, spec?: OperationObject) { - return function( - target: any, - propertyKey: string, - descriptor: PropertyDescriptor, - ) { - assert( - typeof target[propertyKey] === 'function', - '@operation decorator can be applied to methods only', - ); - - const endpoints = getEndpoints(target); - let endpoint = endpoints[propertyKey]; - if (!endpoint || endpoint.target !== target) { - // Add the new endpoint metadata for the method - endpoint = {verb, path, spec, target}; - endpoints[propertyKey] = endpoint; - } else { - // Update the endpoint metadata - // It can be created by @param - endpoint.verb = verb; - endpoint.path = path; - endpoint.target = target; - } - - if (!spec) { - // Users can define parameters and responses using decorators - return; - } - - // Decorator are invoked in reverse order of their definition. - // For example, a method decorated with @operation() @param() - // will invoke param() decorator first and operation() second. - // As a result, we need to preserve any partial definitions - // already provided by other decorators. - editOperationSpec(endpoint, overrides => { - const mergedSpec = Object.assign({}, spec, overrides); - - // Merge "responses" definitions - mergedSpec.responses = Object.assign( - {}, - spec.responses, - overrides.responses, - ); - - return mergedSpec; - }); - }; + return MethodDecoratorFactory.createDecorator>( + REST_METHODS_KEY, + { + verb, + path, + spec, + }, + ); } const paramDecoratorStyle = Symbol('ParamDecoratorStyle'); @@ -322,71 +292,41 @@ const paramDecoratorStyle = Symbol('ParamDecoratorStyle'); */ export function param(paramSpec: ParameterObject) { return function( - target: any, - propertyKey: string, - descriptorOrParameterIndex: PropertyDescriptor | number, + target: Object, + member: string | symbol, + descriptorOrIndex: TypedPropertyDescriptor | number, ) { - assert( - typeof target[propertyKey] === 'function', - '@param decorator can be applied to methods only', - ); - - const endpoints = getEndpoints(target); - let endpoint = endpoints[propertyKey]; - if (!endpoint || endpoint.target !== target) { - const baseEndpoint = endpoint; - // Add the new endpoint metadata for the method - endpoint = cloneDeep(baseEndpoint); - endpoint.target = target; - endpoints[propertyKey] = endpoint; - } - - editOperationSpec(endpoint, operationSpec => { - let decoratorStyle; - if (typeof descriptorOrParameterIndex === 'number') { - decoratorStyle = 'parameter'; - } else { - decoratorStyle = 'method'; - } - if (!operationSpec.parameters) { - operationSpec.parameters = []; - // Record the @param decorator style to ensure consistency - operationSpec[paramDecoratorStyle] = decoratorStyle; - } else { - // Mixed usage of @param at method/parameter level is not allowed - if (operationSpec[paramDecoratorStyle] !== decoratorStyle) { - throw new Error( - 'Mixed usage of @param at method/parameter level' + - ' is not allowed.', - ); - } + if (typeof descriptorOrIndex === 'number') { + if ((target)[paramDecoratorStyle] === 'method') { + throw new Error( + 'Mixed usage of @param at method/parameter level' + + ' is not allowed.', + ); } - - if (typeof descriptorOrParameterIndex === 'number') { - operationSpec.parameters[descriptorOrParameterIndex] = paramSpec; - } else { - operationSpec.parameters.unshift(paramSpec); + (target)[paramDecoratorStyle] = 'parameter'; + ParameterDecoratorFactory.createDecorator( + REST_PARAMETERS_KEY, + paramSpec, + )(target, member, descriptorOrIndex); + } else { + if ((target)[paramDecoratorStyle] === 'parameter') { + throw new Error( + 'Mixed usage of @param at method/parameter level' + + ' is not allowed.', + ); } - - return operationSpec; - }); + (target)[paramDecoratorStyle] = 'method'; + RestMethodParameterDecoratorFactory.createDecorator( + REST_METHODS_WITH_PARAMETERS_KEY, + paramSpec, + )(target, member, descriptorOrIndex); + } }; } -function editOperationSpec( - endpoint: Partial, - updateFn: (spec: OperationObject) => OperationObject, -) { - let spec = endpoint.spec; - if (!spec) { - spec = { - responses: {}, - }; - } - - spec = updateFn(spec); - endpoint.spec = spec; -} +class RestMethodParameterDecoratorFactory extends MethodParameterDecoratorFactory< + ParameterObject +> {} export namespace param { export const query = {