From 8aec1bd6b78130298b752fe589dcebdb3b70d1df Mon Sep 17 00:00:00 2001 From: jannyHou Date: Tue, 20 Feb 2018 15:40:07 -0500 Subject: [PATCH] refactor(openapi-v2): break down controller-spec --- packages/openapi-v2/src/api-decorator.ts | 32 + packages/openapi-v2/src/controller-spec.ts | 565 +----------------- packages/openapi-v2/src/generate-schema.ts | 66 ++ packages/openapi-v2/src/index.ts | 4 + packages/openapi-v2/src/json-to-schema.ts | 86 +++ packages/openapi-v2/src/keys.ts | 12 + .../openapi-v2/src/operation-decorator.ts | 88 +++ .../openapi-v2/src/parameter-decorator.ts | 308 ++++++++++ 8 files changed, 608 insertions(+), 553 deletions(-) create mode 100644 packages/openapi-v2/src/api-decorator.ts create mode 100644 packages/openapi-v2/src/generate-schema.ts create mode 100644 packages/openapi-v2/src/json-to-schema.ts create mode 100644 packages/openapi-v2/src/keys.ts create mode 100644 packages/openapi-v2/src/operation-decorator.ts create mode 100644 packages/openapi-v2/src/parameter-decorator.ts diff --git a/packages/openapi-v2/src/api-decorator.ts b/packages/openapi-v2/src/api-decorator.ts new file mode 100644 index 000000000000..233ed8052040 --- /dev/null +++ b/packages/openapi-v2/src/api-decorator.ts @@ -0,0 +1,32 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v2 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {ControllerSpec} from './controller-spec'; +import {ClassDecoratorFactory} from '@loopback/context'; +import {OAI2Keys} from './keys'; + +/** + * Decorate the given Controller constructor with metadata describing + * the HTTP/REST API the Controller implements/provides. + * + * `@api` can be applied to controller classes. For example, + * ``` + * @api({basePath: '/my'}) + * class MyController { + * // ... + * } + * ``` + * + * @param spec OpenAPI specification describing the endpoints + * handled by this controller + * + * @decorator + */ +export function api(spec: ControllerSpec) { + return ClassDecoratorFactory.createDecorator( + OAI2Keys.CLASS_KEY, + spec, + ); +} diff --git a/packages/openapi-v2/src/controller-spec.ts b/packages/openapi-v2/src/controller-spec.ts index 1d9a885c742d..25dc1396ed7b 100644 --- a/packages/openapi-v2/src/controller-spec.ts +++ b/packages/openapi-v2/src/controller-spec.ts @@ -3,41 +3,24 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import { - MetadataInspector, - ClassDecoratorFactory, - MethodDecoratorFactory, - ParameterDecoratorFactory, - DecoratorFactory, - MethodParameterDecoratorFactory, -} from '@loopback/context'; +import {MetadataInspector, DecoratorFactory} from '@loopback/context'; import { OperationObject, - ParameterLocation, ParameterObject, - SchemaObject, - ParameterType, PathsObject, - ItemType, - ItemsObject, DefinitionsObject, } from '@loopback/openapi-spec'; -import * as stream from 'stream'; -import {getJsonSchema, JsonDefinition} from '@loopback/repository-json-schema'; +import {getJsonSchema} from '@loopback/repository-json-schema'; +import {OAI2Keys} from './keys'; +import {jsonToSchemaObject} from './json-to-schema'; +import {isReadableStream} from './generate-schema'; import * as _ from 'lodash'; const debug = require('debug')('loopback:rest:router:metadata'); -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_CONTROLLER_SPEC_KEY = 'rest:controller-spec'; - // tslint:disable:no-any - export interface ControllerSpec { /** * The base path on which the Controller API is served. @@ -56,34 +39,11 @@ export interface ControllerSpec { */ definitions?: DefinitionsObject; } -/** - * Decorate the given Controller constructor with metadata describing - * the HTTP/REST API the Controller implements/provides. - * - * `@api` can be applied to controller classes. For example, - * ``` - * @api({basePath: '/my'}) - * class MyController { - * // ... - * } - * ``` - * - * @param spec OpenAPI specification describing the endpoints - * handled by this controller - * - * @decorator - */ -export function api(spec: ControllerSpec) { - return ClassDecoratorFactory.createDecorator( - REST_CLASS_KEY, - spec, - ); -} /** * Data structure for REST related metadata */ -interface RestEndpoint { +export interface RestEndpoint { verb: string; path: string; spec?: OperationObject; @@ -97,7 +57,7 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { debug(`Retrieving OpenAPI specification for controller ${constructor.name}`); let spec = MetadataInspector.getClassMetadata( - REST_CLASS_KEY, + OAI2Keys.CLASS_KEY, constructor, ); if (spec) { @@ -109,7 +69,7 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { let endpoints = MetadataInspector.getAllMethodMetadata( - REST_METHODS_KEY, + OAI2Keys.METHODS_KEY, constructor.prototype, ) || {}; @@ -141,13 +101,13 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { debug(' processing parameters for method %s', op); let params = MetadataInspector.getAllParameterMetadata( - REST_PARAMETERS_KEY, + OAI2Keys.PARAMETERS_KEY, constructor.prototype, op, ); if (params == null) { params = MetadataInspector.getMethodMetadata( - REST_METHODS_WITH_PARAMETERS_KEY, + OAI2Keys.METHODS_WITH_PARAMETERS_KEY, constructor.prototype, op, ); @@ -226,518 +186,17 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { */ export function getControllerSpec(constructor: Function): ControllerSpec { let spec = MetadataInspector.getClassMetadata( - REST_CONTROLLER_SPEC_KEY, + OAI2Keys.CONTROLLER_SPEC_KEY, constructor, {ownMetadataOnly: true}, ); if (!spec) { spec = resolveControllerSpec(constructor); MetadataInspector.defineMetadata( - REST_CONTROLLER_SPEC_KEY, + OAI2Keys.CONTROLLER_SPEC_KEY, spec, constructor, ); } return spec; } - -export function jsonToSchemaObject(jsonDef: JsonDefinition): SchemaObject { - const json = jsonDef as {[name: string]: any}; // gets around index signature error - const result: SchemaObject = {}; - const propsToIgnore = [ - 'anyOf', - 'oneOf', - 'additionalItems', - 'defaultProperties', - 'typeof', - ]; - for (const property in json) { - if (propsToIgnore.includes(property)) { - continue; - } - switch (property) { - case 'type': { - if (json.type === 'array' && !json.items) { - throw new Error( - '"items" property must be present if "type" is an array', - ); - } - result.type = Array.isArray(json.type) ? json.type[0] : json.type; - break; - } - case 'allOf': { - result.allOf = _.map(json.allOf, item => jsonToSchemaObject(item)); - break; - } - case 'definitions': { - result.definitions = _.mapValues(json.definitions, def => - jsonToSchemaObject(def), - ); - break; - } - case 'properties': { - result.properties = _.mapValues(json.properties, item => - jsonToSchemaObject(item), - ); - break; - } - case 'additionalProperties': { - if (typeof json.additionalProperties !== 'boolean') { - result.additionalProperties = jsonToSchemaObject( - json.additionalProperties as JsonDefinition, - ); - } - break; - } - case 'items': { - const items = Array.isArray(json.items) ? json.items[0] : json.items; - result.items = jsonToSchemaObject(items as JsonDefinition); - break; - } - case 'enum': { - const newEnum = []; - const primitives = ['string', 'number', 'boolean']; - for (const element of json.enum) { - if (primitives.includes(typeof element) || element === null) { - newEnum.push(element); - } else { - // if element is JsonDefinition, convert to SchemaObject - newEnum.push(jsonToSchemaObject(element as JsonDefinition)); - } - } - result.enum = newEnum; - - break; - } - default: { - result[property] = json[property]; - break; - } - } - } - - return result; -} - -/** - * Expose a Controller method as a REST API operation - * mapped to `GET` request method. - * - * @param path The URL path of this operation, e.g. `/product/{id}` - * @param spec The OpenAPI specification describing parameters and responses - * of this operation. - */ -export function get(path: string, spec?: OperationObject) { - return operation('get', path, spec); -} - -/** - * Expose a Controller method as a REST API operation - * mapped to `POST` request method. - * - * @param path The URL path of this operation, e.g. `/product/{id}` - * @param spec The OpenAPI specification describing parameters and responses - * of this operation. - */ -export function post(path: string, spec?: OperationObject) { - return operation('post', path, spec); -} - -/** - * Expose a Controller method as a REST API operation - * mapped to `PUT` request method. - * - * @param path The URL path of this operation, e.g. `/product/{id}` - * @param spec The OpenAPI specification describing parameters and responses - * of this operation. - */ -export function put(path: string, spec?: OperationObject) { - return operation('put', path, spec); -} - -/** - * Expose a Controller method as a REST API operation - * mapped to `PATCH` request method. - * - * @param path The URL path of this operation, e.g. `/product/{id}` - * @param spec The OpenAPI specification describing parameters and responses - * of this operation. - */ -export function patch(path: string, spec?: OperationObject) { - return operation('patch', path, spec); -} - -/** - * Expose a Controller method as a REST API operation - * mapped to `DELETE` request method. - * - * @param path The URL path of this operation, e.g. `/product/{id}` - * @param spec The OpenAPI specification describing parameters and responses - * of this operation. - */ -export function del(path: string, spec?: OperationObject) { - return operation('delete', path, spec); -} - -/** - * Expose a Controller method as a REST API operation. - * - * @param verb HTTP verb, e.g. `GET` or `POST`. - * @param path The URL path of this operation, e.g. `/product/{id}` - * @param spec The OpenAPI specification describing parameters and responses - * of this operation. - */ -export function operation(verb: string, path: string, spec?: OperationObject) { - return MethodDecoratorFactory.createDecorator>( - REST_METHODS_KEY, - { - verb, - path, - spec, - }, - ); -} - -const paramDecoratorStyle = Symbol('ParamDecoratorStyle'); - -/** - * Check if the given type is `stream.Readable` or a subclasses of - * `stream.Readable` - * @param type JavaScript type function - */ -function isReadableStream(type: Object): boolean { - if (typeof type !== 'function') return false; - if (type === stream.Readable) return true; - return isReadableStream(Object.getPrototypeOf(type)); -} - -/** - * Get openapi type name for a JavaScript type - * @param type JavaScript type - */ -function getTypeForNonBodyParam(type: Function): ParameterType { - if (type === String) { - return 'string'; - } else if (type === Number) { - return 'number'; - } else if (type === Boolean) { - return 'boolean'; - } else if (type === Array) { - return 'array'; - } else if (isReadableStream(type)) { - return 'file'; - } - return 'string'; -} - -/** - * Get openapi schema for a JavaScript type for a body parameter - * @param type JavaScript type - */ -function getSchemaForBodyParam(type: Function): SchemaObject { - const schema: SchemaObject = {}; - let typeName; - if (type === String) { - typeName = 'string'; - } else if (type === Number) { - typeName = 'number'; - } else if (type === Boolean) { - typeName = 'boolean'; - } else if (type === Array) { - // item type cannot be inspected - typeName = 'array'; - } else if (isReadableStream(type)) { - typeName = 'file'; - } else if (type === Object) { - typeName = 'object'; - } - if (typeName) { - schema.type = typeName; - } else { - schema.$ref = '#/definitions/' + type.name; - } - return schema; -} - -/** - * Describe an input parameter of a Controller method. - * - * `@param` can be applied to method itself or specific parameters. For example, - * ``` - * class MyController { - * @get('/') - * @param(offsetSpec) - * @param(pageSizeSpec) - * list(offset?: number, pageSize?: number) {} - * } - * ``` - * or - * ``` - * class MyController { - * @get('/') - * list( - * @param(offsetSpec) offset?: number, - * @param(pageSizeSpec) pageSize?: number, - * ) {} - * } - * ``` - * Please note mixed usage of `@param` at method/parameter level is not allowed. - * - * @param paramSpec Parameter specification. - */ -export function param(paramSpec: ParameterObject) { - return function( - target: Object, - member: string | symbol, - descriptorOrIndex: TypedPropertyDescriptor | number, - ) { - paramSpec = paramSpec || {}; - // Get the design time method parameter metadata - const methodSig = MetadataInspector.getDesignTypeForMethod(target, member); - const paramTypes = (methodSig && methodSig.parameterTypes) || []; - - const targetWithParamStyle = target as any; - if (typeof descriptorOrIndex === 'number') { - if (targetWithParamStyle[paramDecoratorStyle] === 'method') { - // This should not happen as parameter decorators are applied before - // the method decorator - /* istanbul ignore next */ - throw new Error( - 'Mixed usage of @param at method/parameter level' + - ' is not allowed.', - ); - } - // Map design-time parameter type to the OpenAPI param type - - let paramType = paramTypes[descriptorOrIndex]; - if (paramType) { - if (paramSpec.in !== 'body') { - if (!paramSpec.type) { - paramSpec.type = getTypeForNonBodyParam(paramType); - } - } else { - paramSpec.schema = Object.assign( - getSchemaForBodyParam(paramType), - paramSpec.schema, - ); - } - } - - if ( - paramSpec.type === 'array' || - (paramSpec.schema && paramSpec.schema.type === 'array') - ) { - paramType = paramTypes[descriptorOrIndex]; - // The design-time type is `Object` for `any` - if (paramType != null && paramType !== Object && paramType !== Array) { - throw new Error( - `The parameter type is set to 'array' but the JavaScript type is ${ - paramType.name - }`, - ); - } - } - targetWithParamStyle[paramDecoratorStyle] = 'parameter'; - ParameterDecoratorFactory.createDecorator( - REST_PARAMETERS_KEY, - paramSpec, - )(target, member, descriptorOrIndex); - } else { - if (targetWithParamStyle[paramDecoratorStyle] === 'parameter') { - throw new Error( - 'Mixed usage of @param at method/parameter level' + - ' is not allowed.', - ); - } - targetWithParamStyle[paramDecoratorStyle] = 'method'; - RestMethodParameterDecoratorFactory.createDecorator( - REST_METHODS_WITH_PARAMETERS_KEY, - paramSpec, - )(target, member, descriptorOrIndex); - } - }; -} - -class RestMethodParameterDecoratorFactory extends MethodParameterDecoratorFactory< - ParameterObject -> {} - -export namespace param { - export const query = { - /** - * Define a parameter of "string" type that's read from the query string. - * - * @param name Parameter name. - */ - string: createParamShortcut('query', 'string'), - - /** - * Define a parameter of "number" type that's read from the query string. - * - * @param name Parameter name. - */ - number: createParamShortcut('query', 'number'), - - /** - * Define a parameter of "integer" type that's read from the query string. - * - * @param name Parameter name. - */ - integer: createParamShortcut('query', 'integer'), - - /** - * Define a parameter of "boolean" type that's read from the query string. - * - * @param name Parameter name. - */ - boolean: createParamShortcut('query', 'boolean'), - }; - - export const header = { - /** - * Define a parameter of "string" type that's read from a request header. - * - * @param name Parameter name, it must match the header name - * (e.g. `Content-Type`). - */ - string: createParamShortcut('header', 'string'), - - /** - * Define a parameter of "number" type that's read from a request header. - * - * @param name Parameter name, it must match the header name - * (e.g. `Content-Length`). - */ - number: createParamShortcut('header', 'number'), - - /** - * Define a parameter of "integer" type that's read from a request header. - * - * @param name Parameter name, it must match the header name - * (e.g. `Content-Length`). - */ - integer: createParamShortcut('header', 'integer'), - - /** - * Define a parameter of "boolean" type that's read from a request header. - * - * @param name Parameter name, it must match the header name, - * (e.g. `DNT` or `X-Do-Not-Track`). - */ - boolean: createParamShortcut('header', 'boolean'), - }; - - export const path = { - /** - * Define a parameter of "string" type that's read from request path. - * - * @param name Parameter name matching one of the placeholders in the path - * string. - */ - string: createParamShortcut('path', 'string'), - - /** - * Define a parameter of "number" type that's read from request path. - * - * @param name Parameter name matching one of the placeholders in the path - * string. - */ - number: createParamShortcut('path', 'number'), - - /** - * Define a parameter of "integer" type that's read from request path. - * - * @param name Parameter name matching one of the placeholders in the path - * string. - */ - integer: createParamShortcut('path', 'integer'), - - /** - * Define a parameter of "boolean" type that's read from request path. - * - * @param name Parameter name matching one of the placeholders in the path - * string. - */ - boolean: createParamShortcut('path', 'boolean'), - }; - - export const formData = { - /** - * Define a parameter of "string" type that's read - * from a field in the request body. - * - * @param name Parameter name. - */ - string: createParamShortcut('formData', 'string'), - - /** - * Define a parameter of "number" type that's read - * from a field in the request body. - * - * @param name Parameter name. - */ - number: createParamShortcut('formData', 'number'), - - /** - * Define a parameter of "integer" type that's read - * from a field in the request body. - * - * @param name Parameter name. - */ - integer: createParamShortcut('formData', 'integer'), - - /** - * Define a parameter of "boolean" type that's read - * from a field in the request body. - * - * @param name Parameter name. - */ - boolean: createParamShortcut('formData', 'boolean'), - }; - - /** - * Define a parameter that's set to the full request body. - * - * @param name Parameter name - * @param schema The schema defining the type used for the body parameter. - */ - export const body = function(name: string, schema?: SchemaObject) { - return param({name, in: 'body', schema}); - }; - - /** - * Define a parameter of `array` type - * - * @example - * ```ts - * export class MyController { - * @get('/greet') - * greet(@param.array('names', 'query', 'string') names: string[]): string { - * return `Hello, ${names}`; - * } - * } - * ``` - * @param name Parameter name - * @param source Source of the parameter value - * @param itemSpec Item type for the array or the full item object - */ - export const array = function( - name: string, - source: ParameterLocation, - itemSpec: ItemType | ItemsObject, - ) { - const items = typeof itemSpec === 'string' ? {type: itemSpec} : itemSpec; - if (source !== 'body') { - return param({name, in: source, type: 'array', items}); - } else { - return param({name, in: source, schema: {type: 'array', items}}); - } - }; -} - -function createParamShortcut(source: ParameterLocation, type: ParameterType) { - // TODO(bajtos) @param.IN.TYPE('foo', {required: true}) - return (name: string) => { - return param({name, in: source, type}); - }; -} diff --git a/packages/openapi-v2/src/generate-schema.ts b/packages/openapi-v2/src/generate-schema.ts new file mode 100644 index 000000000000..9670bab89d46 --- /dev/null +++ b/packages/openapi-v2/src/generate-schema.ts @@ -0,0 +1,66 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v2 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {ParameterType, SchemaObject} from '@loopback/openapi-spec'; +import * as stream from 'stream'; + +/** + * Get openapi type name for a JavaScript type + * @param type JavaScript type + */ +export function getTypeForNonBodyParam(type: Function): ParameterType { + if (type === String) { + return 'string'; + } else if (type === Number) { + return 'number'; + } else if (type === Boolean) { + return 'boolean'; + } else if (type === Array) { + return 'array'; + } else if (isReadableStream(type)) { + return 'file'; + } + return 'string'; +} + +/** + * Get openapi schema for a JavaScript type for a body parameter + * @param type JavaScript type + */ +export function getSchemaForBodyParam(type: Function): SchemaObject { + const schema: SchemaObject = {}; + let typeName; + if (type === String) { + typeName = 'string'; + } else if (type === Number) { + typeName = 'number'; + } else if (type === Boolean) { + typeName = 'boolean'; + } else if (type === Array) { + // item type cannot be inspected + typeName = 'array'; + } else if (isReadableStream(type)) { + typeName = 'file'; + } else if (type === Object) { + typeName = 'object'; + } + if (typeName) { + schema.type = typeName; + } else { + schema.$ref = '#/definitions/' + type.name; + } + return schema; +} + +/** + * Check if the given type is `stream.Readable` or a subclasses of + * `stream.Readable` + * @param type JavaScript type function + */ +export function isReadableStream(type: Object): boolean { + if (typeof type !== 'function') return false; + if (type === stream.Readable) return true; + return isReadableStream(Object.getPrototypeOf(type)); +} diff --git a/packages/openapi-v2/src/index.ts b/packages/openapi-v2/src/index.ts index 33f6ed6d2e05..50c09dabed2e 100644 --- a/packages/openapi-v2/src/index.ts +++ b/packages/openapi-v2/src/index.ts @@ -3,4 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +export * from './api-decorator'; export * from './controller-spec'; +export * from './json-to-schema'; +export * from './parameter-decorator'; +export * from './operation-decorator'; diff --git a/packages/openapi-v2/src/json-to-schema.ts b/packages/openapi-v2/src/json-to-schema.ts new file mode 100644 index 000000000000..ed6fc04cab2a --- /dev/null +++ b/packages/openapi-v2/src/json-to-schema.ts @@ -0,0 +1,86 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v2 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {JsonDefinition} from '@loopback/repository-json-schema'; +import {SchemaObject, ExtensionValue} from '@loopback/openapi-spec'; +import * as _ from 'lodash'; + +export function jsonToSchemaObject(jsonDef: JsonDefinition): SchemaObject { + const json = jsonDef as {[name: string]: ExtensionValue}; // gets around index signature error + const result: SchemaObject = {}; + const propsToIgnore = [ + 'anyOf', + 'oneOf', + 'additionalItems', + 'defaultProperties', + 'typeof', + ]; + for (const property in json) { + if (propsToIgnore.includes(property)) { + continue; + } + switch (property) { + case 'type': { + if (json.type === 'array' && !json.items) { + throw new Error( + '"items" property must be present if "type" is an array', + ); + } + result.type = Array.isArray(json.type) ? json.type[0] : json.type; + break; + } + case 'allOf': { + result.allOf = _.map(json.allOf, item => jsonToSchemaObject(item)); + break; + } + case 'definitions': { + result.definitions = _.mapValues(json.definitions, def => + jsonToSchemaObject(def), + ); + break; + } + case 'properties': { + result.properties = _.mapValues(json.properties, item => + jsonToSchemaObject(item), + ); + break; + } + case 'additionalProperties': { + if (typeof json.additionalProperties !== 'boolean') { + result.additionalProperties = jsonToSchemaObject( + json.additionalProperties as JsonDefinition, + ); + } + break; + } + case 'items': { + const items = Array.isArray(json.items) ? json.items[0] : json.items; + result.items = jsonToSchemaObject(items as JsonDefinition); + break; + } + case 'enum': { + const newEnum = []; + const primitives = ['string', 'number', 'boolean']; + for (const element of json.enum) { + if (primitives.includes(typeof element) || element === null) { + newEnum.push(element); + } else { + // if element is JsonDefinition, convert to SchemaObject + newEnum.push(jsonToSchemaObject(element as JsonDefinition)); + } + } + result.enum = newEnum; + + break; + } + default: { + result[property] = json[property]; + break; + } + } + } + + return result; +} diff --git a/packages/openapi-v2/src/keys.ts b/packages/openapi-v2/src/keys.ts new file mode 100644 index 000000000000..eab5c2addce0 --- /dev/null +++ b/packages/openapi-v2/src/keys.ts @@ -0,0 +1,12 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v2 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export namespace OAI2Keys { + export const METHODS_KEY = 'openapi-v2:methods'; + export const METHODS_WITH_PARAMETERS_KEY = 'openapi-v2:methods:parameters'; + export const PARAMETERS_KEY = 'openapi-v2:parameters'; + export const CLASS_KEY = 'openapi-v2:class'; + export const CONTROLLER_SPEC_KEY = 'openapi-v2:controller-spec'; +} diff --git a/packages/openapi-v2/src/operation-decorator.ts b/packages/openapi-v2/src/operation-decorator.ts new file mode 100644 index 000000000000..c99e2201fb87 --- /dev/null +++ b/packages/openapi-v2/src/operation-decorator.ts @@ -0,0 +1,88 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v2 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {OperationObject} from '@loopback/openapi-spec'; +import {MethodDecoratorFactory} from '@loopback/context'; +import {RestEndpoint} from './controller-spec'; +import {OAI2Keys} from './keys'; + +/** + * Expose a Controller method as a REST API operation + * mapped to `GET` request method. + * + * @param path The URL path of this operation, e.g. `/product/{id}` + * @param spec The OpenAPI specification describing parameters and responses + * of this operation. + */ +export function get(path: string, spec?: OperationObject) { + return operation('get', path, spec); +} + +/** + * Expose a Controller method as a REST API operation + * mapped to `POST` request method. + * + * @param path The URL path of this operation, e.g. `/product/{id}` + * @param spec The OpenAPI specification describing parameters and responses + * of this operation. + */ +export function post(path: string, spec?: OperationObject) { + return operation('post', path, spec); +} + +/** + * Expose a Controller method as a REST API operation + * mapped to `PUT` request method. + * + * @param path The URL path of this operation, e.g. `/product/{id}` + * @param spec The OpenAPI specification describing parameters and responses + * of this operation. + */ +export function put(path: string, spec?: OperationObject) { + return operation('put', path, spec); +} + +/** + * Expose a Controller method as a REST API operation + * mapped to `PATCH` request method. + * + * @param path The URL path of this operation, e.g. `/product/{id}` + * @param spec The OpenAPI specification describing parameters and responses + * of this operation. + */ +export function patch(path: string, spec?: OperationObject) { + return operation('patch', path, spec); +} + +/** + * Expose a Controller method as a REST API operation + * mapped to `DELETE` request method. + * + * @param path The URL path of this operation, e.g. `/product/{id}` + * @param spec The OpenAPI specification describing parameters and responses + * of this operation. + */ +export function del(path: string, spec?: OperationObject) { + return operation('delete', path, spec); +} + +/** + * Expose a Controller method as a REST API operation. + * + * @param verb HTTP verb, e.g. `GET` or `POST`. + * @param path The URL path of this operation, e.g. `/product/{id}` + * @param spec The OpenAPI specification describing parameters and responses + * of this operation. + */ +export function operation(verb: string, path: string, spec?: OperationObject) { + return MethodDecoratorFactory.createDecorator>( + OAI2Keys.METHODS_KEY, + { + verb, + path, + spec, + }, + ); +} diff --git a/packages/openapi-v2/src/parameter-decorator.ts b/packages/openapi-v2/src/parameter-decorator.ts new file mode 100644 index 000000000000..d4a242c5548f --- /dev/null +++ b/packages/openapi-v2/src/parameter-decorator.ts @@ -0,0 +1,308 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v2 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + ParameterObject, + SchemaObject, + ParameterLocation, + ItemsObject, + ItemType, + ParameterType, + ExtensionValue, +} from '@loopback/openapi-spec'; + +import { + MetadataInspector, + ParameterDecoratorFactory, + MethodParameterDecoratorFactory, +} from '@loopback/context'; + +import {OAI2Keys} from './keys'; +import {getSchemaForBodyParam, getTypeForNonBodyParam} from './generate-schema'; + +const paramDecoratorStyle = Symbol('ParamDecoratorStyle'); + +/** + * Describe an input parameter of a Controller method. + * + * `@param` can be applied to method itself or specific parameters. For example, + * ``` + * class MyController { + * @get('/') + * @param(offsetSpec) + * @param(pageSizeSpec) + * list(offset?: number, pageSize?: number) {} + * } + * ``` + * or + * ``` + * class MyController { + * @get('/') + * list( + * @param(offsetSpec) offset?: number, + * @param(pageSizeSpec) pageSize?: number, + * ) {} + * } + * ``` + * Please note mixed usage of `@param` at method/parameter level is not allowed. + * + * @param paramSpec Parameter specification. + */ +export function param(paramSpec: ParameterObject) { + return function( + target: Object, + member: string | symbol, + descriptorOrIndex: TypedPropertyDescriptor | number, + ) { + paramSpec = paramSpec || {}; + // Get the design time method parameter metadata + const methodSig = MetadataInspector.getDesignTypeForMethod(target, member); + const paramTypes = (methodSig && methodSig.parameterTypes) || []; + + const targetWithParamStyle = target as ExtensionValue; + if (typeof descriptorOrIndex === 'number') { + if (targetWithParamStyle[paramDecoratorStyle] === 'method') { + // This should not happen as parameter decorators are applied before + // the method decorator + /* istanbul ignore next */ + throw new Error( + 'Mixed usage of @param at method/parameter level' + + ' is not allowed.', + ); + } + // Map design-time parameter type to the OpenAPI param type + + let paramType = paramTypes[descriptorOrIndex]; + if (paramType) { + if (paramSpec.in !== 'body') { + if (!paramSpec.type) { + paramSpec.type = getTypeForNonBodyParam(paramType); + } + } else { + paramSpec.schema = Object.assign( + getSchemaForBodyParam(paramType), + paramSpec.schema, + ); + } + } + + if ( + paramSpec.type === 'array' || + (paramSpec.schema && paramSpec.schema.type === 'array') + ) { + paramType = paramTypes[descriptorOrIndex]; + // The design-time type is `Object` for `any` + if (paramType != null && paramType !== Object && paramType !== Array) { + throw new Error( + `The parameter type is set to 'array' but the JavaScript type is ${ + paramType.name + }`, + ); + } + } + targetWithParamStyle[paramDecoratorStyle] = 'parameter'; + ParameterDecoratorFactory.createDecorator( + OAI2Keys.PARAMETERS_KEY, + paramSpec, + )(target, member, descriptorOrIndex); + } else { + if (targetWithParamStyle[paramDecoratorStyle] === 'parameter') { + throw new Error( + 'Mixed usage of @param at method/parameter level' + + ' is not allowed.', + ); + } + targetWithParamStyle[paramDecoratorStyle] = 'method'; + RestMethodParameterDecoratorFactory.createDecorator( + OAI2Keys.METHODS_WITH_PARAMETERS_KEY, + paramSpec, + )(target, member, descriptorOrIndex); + } + }; +} + +class RestMethodParameterDecoratorFactory extends MethodParameterDecoratorFactory< + ParameterObject +> {} + +export namespace param { + export const query = { + /** + * Define a parameter of "string" type that's read from the query string. + * + * @param name Parameter name. + */ + string: createParamShortcut('query', 'string'), + + /** + * Define a parameter of "number" type that's read from the query string. + * + * @param name Parameter name. + */ + number: createParamShortcut('query', 'number'), + + /** + * Define a parameter of "integer" type that's read from the query string. + * + * @param name Parameter name. + */ + integer: createParamShortcut('query', 'integer'), + + /** + * Define a parameter of "boolean" type that's read from the query string. + * + * @param name Parameter name. + */ + boolean: createParamShortcut('query', 'boolean'), + }; + + export const header = { + /** + * Define a parameter of "string" type that's read from a request header. + * + * @param name Parameter name, it must match the header name + * (e.g. `Content-Type`). + */ + string: createParamShortcut('header', 'string'), + + /** + * Define a parameter of "number" type that's read from a request header. + * + * @param name Parameter name, it must match the header name + * (e.g. `Content-Length`). + */ + number: createParamShortcut('header', 'number'), + + /** + * Define a parameter of "integer" type that's read from a request header. + * + * @param name Parameter name, it must match the header name + * (e.g. `Content-Length`). + */ + integer: createParamShortcut('header', 'integer'), + + /** + * Define a parameter of "boolean" type that's read from a request header. + * + * @param name Parameter name, it must match the header name, + * (e.g. `DNT` or `X-Do-Not-Track`). + */ + boolean: createParamShortcut('header', 'boolean'), + }; + + export const path = { + /** + * Define a parameter of "string" type that's read from request path. + * + * @param name Parameter name matching one of the placeholders in the path + * string. + */ + string: createParamShortcut('path', 'string'), + + /** + * Define a parameter of "number" type that's read from request path. + * + * @param name Parameter name matching one of the placeholders in the path + * string. + */ + number: createParamShortcut('path', 'number'), + + /** + * Define a parameter of "integer" type that's read from request path. + * + * @param name Parameter name matching one of the placeholders in the path + * string. + */ + integer: createParamShortcut('path', 'integer'), + + /** + * Define a parameter of "boolean" type that's read from request path. + * + * @param name Parameter name matching one of the placeholders in the path + * string. + */ + boolean: createParamShortcut('path', 'boolean'), + }; + + export const formData = { + /** + * Define a parameter of "string" type that's read + * from a field in the request body. + * + * @param name Parameter name. + */ + string: createParamShortcut('formData', 'string'), + + /** + * Define a parameter of "number" type that's read + * from a field in the request body. + * + * @param name Parameter name. + */ + number: createParamShortcut('formData', 'number'), + + /** + * Define a parameter of "integer" type that's read + * from a field in the request body. + * + * @param name Parameter name. + */ + integer: createParamShortcut('formData', 'integer'), + + /** + * Define a parameter of "boolean" type that's read + * from a field in the request body. + * + * @param name Parameter name. + */ + boolean: createParamShortcut('formData', 'boolean'), + }; + + /** + * Define a parameter that's set to the full request body. + * + * @param name Parameter name + * @param schema The schema defining the type used for the body parameter. + */ + export const body = function(name: string, schema?: SchemaObject) { + return param({name, in: 'body', schema}); + }; + + /** + * Define a parameter of `array` type + * + * @example + * ```ts + * export class MyController { + * @get('/greet') + * greet(@param.array('names', 'query', 'string') names: string[]): string { + * return `Hello, ${names}`; + * } + * } + * ``` + * @param name Parameter name + * @param source Source of the parameter value + * @param itemSpec Item type for the array or the full item object + */ + export const array = function( + name: string, + source: ParameterLocation, + itemSpec: ItemType | ItemsObject, + ) { + const items = typeof itemSpec === 'string' ? {type: itemSpec} : itemSpec; + if (source !== 'body') { + return param({name, in: source, type: 'array', items}); + } else { + return param({name, in: source, schema: {type: 'array', items}}); + } + }; +} + +function createParamShortcut(source: ParameterLocation, type: ParameterType) { + // TODO(bajtos) @param.IN.TYPE('foo', {required: true}) + return (name: string) => { + return param({name, in: source, type}); + }; +}