diff --git a/CODEOWNERS b/CODEOWNERS index 07baad0731f9..381fa969f1cc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -18,6 +18,7 @@ packages/metadata/* @raymondfeng packages/openapi-spec/* @bajtos @jannyHou packages/openapi-spec-builder/* @bajtos @raymondfeng packages/openapi-v2/* @jannyHou +packages/openapi-v3/* @jannyHou packages/openapi-v3-types/* @jannyHou packages/repository/* @raymondfeng @kjdelisle packages/repository-json-schema/* @shimks diff --git a/MONOREPO.md b/MONOREPO.md index 7b58f0c506c4..8814e9652a2f 100644 --- a/MONOREPO.md +++ b/MONOREPO.md @@ -20,6 +20,7 @@ The [loopback-next](https://github.com/strongloop/loopback-next) repository uses |[openapi-spec-builder](packages/openapi-spec-builder) |@loopback/openapi-spec-builder | Builders to create OpenAPI (Swagger) specification documents in tests | |[openapi-spec](packages/openapi-spec) |@loopback/openapi-spec | TypeScript type definitions for OpenAPI Spec/Swagger documents | |[openapi-v2](packages/openapi-v2) |@loopback/openapi-v2 | Decorators that annotate LoopBack artifacts with OpenAPI v2 (Swagger) metadata and utilities that transform LoopBack metadata to OpenAPI v2 (Swagger) specifications| +|[openapi-v3](packages/openapi-v3) |@loopback/openapi-v3 | Decorators that annotate LoopBack artifacts with OpenAPI v3 metadata and utilities that transform LoopBack metadata to OpenAPI v3 specifications| |[openapi-v3-types](packages/openapi-v3-types) |@loopback/openapi-v3-types | TypeScript type definitions for OpenAPI Specifications | |[repository-json-schema](packages/repository-json-schema) |@loopback/repository-json-schema| Convert a TypeScript class/model to a JSON Schema | |[repository](packages/repository) |@loopback/repository | Define and implement a common set of interfaces for interacting with databases| diff --git a/packages/openapi-v3-types/src/openapi-v3-spec-types.ts b/packages/openapi-v3-types/src/openapi-v3-spec-types.ts index b657aba7f2bd..310672f15ab3 100644 --- a/packages/openapi-v3-types/src/openapi-v3-spec-types.ts +++ b/packages/openapi-v3-types/src/openapi-v3-spec-types.ts @@ -258,6 +258,13 @@ export interface MapObject { [name: string]: T; } +/** + * Schemas Object in components + */ +export interface SchemasObject extends MapObject { + [name: string]: SchemaObject; +} + /** * Lists the available scopes for an OAuth2 security scheme. */ diff --git a/packages/openapi-v3/LICENSE b/packages/openapi-v3/LICENSE new file mode 100644 index 000000000000..f301405944a1 --- /dev/null +++ b/packages/openapi-v3/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2018. All Rights Reserved. +Node module: @loopback/openapi-v3 +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/openapi-v3/README.md b/packages/openapi-v3/README.md new file mode 100644 index 000000000000..b5e143a28ac6 --- /dev/null +++ b/packages/openapi-v3/README.md @@ -0,0 +1,88 @@ +@loopback/openapi-v3 + +This package contains: + +* Decorators that describe LoopBack artifacts as OpenAPI 3.0.0 metadata. +* Utilities that transfer LoopBack metadata to OpenAPI 3.0.0 specifications. + +## Overview + +The package has functions described above for LoopBack controller classes. +Decorators apply REST api mapping metadata to controller classes and their members. And utilities that inspect controller classes to build OpenAPI 3.0.0 specifications from REST API mapping metadata. + +Functions for more artifacts will be added when we need. + +## Installation + +``` +$ npm install --save @loopback/openapi-v3 +``` + +## Basic use + +Currently this package only has spec generator for controllers. +It generates OpenAPI specifications for a given decorated controller class, including +`paths`, `components.schemas`, and `servers`. + +Here is an example of calling function `getControllerSpec` to generate the OpenAPI spec: + +```js +import {api, getControllerSpec} from '@loopback/openapi-v3'; + +@api(somePathSpec) +class MyController { + greet() { + return 'Hello world!'; + } +} + +const myControllerSpec = getControllerSpec(MyController); +``` + +then the `myControllerSpec` will be: + +```js +{ + openapi: '3.0.0', + info: { title: 'LoopBack Application', version: '1.0.0' }, + paths: { + '/greet': { + get: { + responses: { + '200': { + description: 'The string result.', + schema: { type: 'string' } + } + }, + 'x-operation-name': 'greet' + } + } + }, + servers: [ + {url: '/'} + ] +} +``` + +For details of how to apply controller decorators, please check http://loopback.io/doc/en/lb4/Decorators.html#route-decorators + +## Related resources + +See https://www.openapis.org/ and [version 3.0.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md) +of OpenAPI Specification. + +## Contributions + +IBM/StrongLoop is an active supporter of open source and welcomes contributions to our projects as well as those of the Node.js community in general. For more information on how to contribute please refer to the [Contribution Guide](https://loopback.io/doc/en/contrib/index.html). + +# Tests + +run `npm test` from the root folder. + +# Contributors + +See [all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +# License + +MIT \ No newline at end of file diff --git a/packages/openapi-v3/docs.json b/packages/openapi-v3/docs.json new file mode 100644 index 000000000000..82bbc5b51ff2 --- /dev/null +++ b/packages/openapi-v3/docs.json @@ -0,0 +1,14 @@ +{ + "content": [ + "src/api-decorator.ts", + "src/controller-spec.ts", + "src/generate-schema.ts", + "src/index.ts", + "src/json-to-schema.ts", + "src/keys.ts", + "src/operation-decorator.ts", + "src/parameter-decorator.ts", + "src/request-body-decorator.ts" + ], + "codeSectionDepth": 6 +} diff --git a/packages/openapi-v3/index.d.ts b/packages/openapi-v3/index.d.ts new file mode 100644 index 000000000000..d8870b6ebd1c --- /dev/null +++ b/packages/openapi-v3/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/packages/openapi-v3/index.js b/packages/openapi-v3/index.js new file mode 100644 index 000000000000..08a03353b9c9 --- /dev/null +++ b/packages/openapi-v3/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +module.exports = require('./dist'); diff --git a/packages/openapi-v3/index.ts b/packages/openapi-v3/index.ts new file mode 100644 index 000000000000..0ee3acb423b7 --- /dev/null +++ b/packages/openapi-v3/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// DO NOT EDIT THIS FILE +// Add any aditional (re)exports to src/index.ts instead. +export * from './src'; diff --git a/packages/openapi-v3/package.json b/packages/openapi-v3/package.json new file mode 100644 index 000000000000..a8349352d24d --- /dev/null +++ b/packages/openapi-v3/package.json @@ -0,0 +1,57 @@ +{ + "name": "@loopback/openapi-v3", + "version": "0.1.0", + "description": "Processes openapi v3 related metadata", + "engines": { + "node": ">=8" + }, + "devDependencies": { + "@loopback/build": "^0.1.1", + "@loopback/openapi-spec-builder": "^0.1.1", + "@loopback/repository": "^0.1.1", + "@loopback/testlab": "^0.1.1", + "@types/debug": "0.0.30", + "@types/lodash": "^4.14.96" + }, + "scripts": { + "build": "lb-tsc es2017", + "build:apidocs": "lb-apidocs", + "clean": "lb-clean loopback-openapi-v3*.tgz dist* package", + "integration": "lb-mocha \"DIST/test/integration/**/*.js\"", + "prepublishOnly": "npm run build && npm run build:apidocs", + "pretest": "npm run build", + "test": "lb-mocha \"DIST/test/unit/**/*.js\" \"DIST/test/integration/**/*.js\"", + "unit": "lb-mocha \"DIST/test/unit/**/*.js\"", + "verify": "npm pack && tar xf loopback-openapi-v3*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "copyright.owner": "IBM Corp.", + "license": "MIT", + "keywords": [ + "Swagger", + "OpenAPI Spec", + "TypeScript" + ], + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist/src", + "api-docs", + "src" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + }, + "dependencies": { + "@loopback/context": "^0.1.1", + "@loopback/openapi-v3-types": "^0.1.1", + "@loopback/repository-json-schema": "^0.1.1", + "debug": "^3.1.0", + "lodash": "^4.17.4" + } +} diff --git a/packages/openapi-v3/src/api-decorator.ts b/packages/openapi-v3/src/api-decorator.ts new file mode 100644 index 000000000000..bac9acc812f0 --- /dev/null +++ b/packages/openapi-v3/src/api-decorator.ts @@ -0,0 +1,32 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// 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 {OAI3Keys} 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( + OAI3Keys.CLASS_KEY, + spec, + ); +} diff --git a/packages/openapi-v3/src/controller-spec.ts b/packages/openapi-v3/src/controller-spec.ts new file mode 100644 index 000000000000..ba067c7edb96 --- /dev/null +++ b/packages/openapi-v3/src/controller-spec.ts @@ -0,0 +1,217 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {MetadataInspector, DecoratorFactory} from '@loopback/context'; + +import { + OperationObject, + ParameterObject, + PathsObject, + ComponentsObject, + RequestBodyObject, +} from '@loopback/openapi-v3-types'; +import {getJsonSchema} from '@loopback/repository-json-schema'; +import {OAI3Keys} from './keys'; +import {jsonToSchemaObject} from './json-to-schema'; +import * as _ from 'lodash'; + +const debug = require('debug')('loopback:openapi3:metadata'); + +// tslint:disable:no-any + +export interface ControllerSpec { + /** + * The base path on which the Controller API is served. + * If it is not included, the API is served directly under the host. + * The value MUST start with a leading slash (/). + */ + basePath?: string; + + /** + * The available paths and operations for the API. + */ + paths: PathsObject; + + /** + * OpenAPI components.schemas generated from model metadata + */ + components?: ComponentsObject; +} + +/** + * Data structure for REST related metadata + */ +export interface RestEndpoint { + verb: string; + path: string; + spec?: OperationObject; +} + +/** + * Build the api spec from class and method level decorations + * @param constructor Controller class + */ +function resolveControllerSpec(constructor: Function): ControllerSpec { + debug(`Retrieving OpenAPI specification for controller ${constructor.name}`); + + let spec = MetadataInspector.getClassMetadata( + OAI3Keys.CLASS_KEY, + constructor, + ); + if (spec) { + debug(' using class-level spec defined via @api()', spec); + spec = DecoratorFactory.cloneDeep(spec); + } else { + spec = {paths: {}}; + } + + let endpoints = + MetadataInspector.getAllMethodMetadata( + OAI3Keys.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 = ''; + /* istanbul ignore if */ + if (debug.enabled) { + const className = constructor.name || ''; + const fullMethodName = `${className}.${op}`; + endpointName = `${fullMethodName} (${verb} ${path})`; + } + + let operationSpec = endpoint.spec; + if (!operationSpec) { + // The operation was defined via @operation(verb, path) with no spec + operationSpec = { + responses: {}, + }; + endpoint.spec = operationSpec; + } + debug(' operation for method %s: %j', op, endpoint); + + debug(' processing parameters for method %s', op); + let params = MetadataInspector.getAllParameterMetadata( + OAI3Keys.PARAMETERS_KEY, + constructor.prototype, + op, + ); + + debug(' parameters for method %s: %j', op, params); + if (params != null) { + params = DecoratorFactory.cloneDeep(params); + /** + * If a controller method uses dependency injection, the parameters + * might be sparsed. For example, + * ```ts + * class MyController { + * greet( + * @inject('prefix') prefix: string, + * @param.query.string('name) name: string) { + * return `${prefix}`, ${name}`; + * } + * ``` + */ + operationSpec.parameters = params.filter(p => p != null); + } + + debug(' processing requestBody for method %s', op); + let requestBodies = MetadataInspector.getAllParameterMetadata< + RequestBodyObject + >(OAI3Keys.REQUEST_BODY_KEY, constructor.prototype, op); + + if (requestBodies != null) + requestBodies = requestBodies.filter(p => p != null); + let requestBody: RequestBodyObject; + + if (requestBodies) { + if (requestBodies.length > 1) + throw new Error( + 'An operation should only have one parameter decorated by @requestBody', + ); + + requestBody = requestBodies[0]; + debug(' requestBody for method %s: %j', op, requestBody); + if (requestBody) { + operationSpec.requestBody = requestBody; + } + } + + operationSpec['x-operation-name'] = op; + + if (!spec.paths[path]) { + spec.paths[path] = {}; + } + + if (spec.paths[path][verb]) { + // Operations from subclasses override those from the base + debug(` Overriding ${endpointName} - endpoint was already defined`); + } + + debug(` adding ${endpointName}`, operationSpec); + spec.paths[path][verb] = operationSpec; + + debug(` inferring schema object for method %s`, op); + const opMetadata = MetadataInspector.getDesignTypeForMethod( + constructor.prototype, + op, + ); + const paramTypes = opMetadata.parameterTypes; + + const isComplexType = (ctor: Function) => + !_.includes([String, Number, Boolean, Array, Object], ctor); + + for (const p of paramTypes) { + if (isComplexType(p)) { + if (!spec.components) { + spec.components = {}; + } + if (!spec.components.schemas) { + spec.components.schemas = {}; + } + const jsonSchema = getJsonSchema(p); + const openapiSchema = jsonToSchemaObject(jsonSchema); + if (openapiSchema.definitions) { + for (const key in openapiSchema.definitions) { + spec.components.schemas[key] = openapiSchema.definitions[key]; + } + delete openapiSchema.definitions; + } + + spec.components.schemas[p.name] = openapiSchema; + break; + } + } + } + return spec; +} + +/** + * Get the controller spec for the given class + * @param constructor Controller class + */ +export function getControllerSpec(constructor: Function): ControllerSpec { + let spec = MetadataInspector.getClassMetadata( + OAI3Keys.CONTROLLER_SPEC_KEY, + constructor, + {ownMetadataOnly: true}, + ); + if (!spec) { + spec = resolveControllerSpec(constructor); + MetadataInspector.defineMetadata( + OAI3Keys.CONTROLLER_SPEC_KEY, + spec, + constructor, + ); + } + return spec; +} diff --git a/packages/openapi-v3/src/generate-schema.ts b/packages/openapi-v3/src/generate-schema.ts new file mode 100644 index 000000000000..cd38ecf4bff1 --- /dev/null +++ b/packages/openapi-v3/src/generate-schema.ts @@ -0,0 +1,65 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {SchemaObject} from '@loopback/openapi-v3-types'; + +/** + * @private + */ +interface TypeAndFormat { + type?: string; + format?: string; +} + +/** + * Generate the `type` and `format` property in a Schema Object according to a + * parameter's type. + * `type` and `format` will be preserved if provided in `schema` + * + * @private + * @param type The JavaScript type of a parameter + * @param schema The schema object provided in an parameter object + */ +export function getSchemaForParam( + type: Function, + schema?: SchemaObject, +): SchemaObject { + schema = schema || {}; + // preserve `type` and `format` provided by user + if (schema.type && schema.format) return schema; + + let typeAndFormat: TypeAndFormat = {}; + if (type === String) { + typeAndFormat.type = 'string'; + } else if (type === Number) { + typeAndFormat.type = 'number'; + } else if (type === Boolean) { + typeAndFormat.type = 'boolean'; + } else if (type === Array) { + // item type cannot be inspected + typeAndFormat.type = 'array'; + } else if (type === Object) { + typeAndFormat.type = 'object'; + } + + if (typeAndFormat.type && !schema.type) schema.type = typeAndFormat.type; + if (typeAndFormat.format && !schema.format) + schema.format = typeAndFormat.format; + + return schema; +} + +/** + * Get OpenAPI Schema for a JavaScript type for a body parameter + * + * @private + * @param type The JavaScript type of an argument deccorated by @requestBody + */ +export function getSchemaForRequestBody(type: Function): SchemaObject { + let generatedSchema = getSchemaForParam(type); + if (!generatedSchema.type) + generatedSchema.$ref = '#/components/schemas/' + type.name; + return generatedSchema; +} diff --git a/packages/openapi-v3/src/index.ts b/packages/openapi-v3/src/index.ts new file mode 100644 index 000000000000..59a77ad86633 --- /dev/null +++ b/packages/openapi-v3/src/index.ts @@ -0,0 +1,12 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// 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 './operation-decorator'; +export * from './parameter-decorator'; +export * from './request-body-decorator'; +export * from './spec-builder/openapi-v3-spec-builder'; diff --git a/packages/openapi-v3/src/json-to-schema.ts b/packages/openapi-v3/src/json-to-schema.ts new file mode 100644 index 000000000000..ea017049bd64 --- /dev/null +++ b/packages/openapi-v3/src/json-to-schema.ts @@ -0,0 +1,93 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// 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-v3-types'; +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; + } + case '$ref': { + result.$ref = json.$ref.replace( + '#/definitions', + '#/components/schemas', + ); + break; + } + default: { + result[property] = json[property]; + break; + } + } + } + + return result; +} diff --git a/packages/openapi-v3/src/keys.ts b/packages/openapi-v3/src/keys.ts new file mode 100644 index 000000000000..573835796fcc --- /dev/null +++ b/packages/openapi-v3/src/keys.ts @@ -0,0 +1,12 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export namespace OAI3Keys { + export const METHODS_KEY = 'openapi-v3:methods'; + export const PARAMETERS_KEY = 'openapi-v3:parameters'; + export const CLASS_KEY = 'openapi-v3:class'; + export const CONTROLLER_SPEC_KEY = 'openapi-v3:controller-spec'; + export const REQUEST_BODY_KEY = 'openapi-v3:request-body'; +} diff --git a/packages/openapi-v3/src/operation-decorator.ts b/packages/openapi-v3/src/operation-decorator.ts new file mode 100644 index 000000000000..884adbdb860d --- /dev/null +++ b/packages/openapi-v3/src/operation-decorator.ts @@ -0,0 +1,88 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {OperationObject} from '@loopback/openapi-v3-types'; +import {MethodDecoratorFactory} from '@loopback/context'; +import {RestEndpoint} from './controller-spec'; +import {OAI3Keys} 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>( + OAI3Keys.METHODS_KEY, + { + verb, + path, + spec, + }, + ); +} diff --git a/packages/openapi-v3/src/parameter-decorator.ts b/packages/openapi-v3/src/parameter-decorator.ts new file mode 100644 index 000000000000..d6bf88d47578 --- /dev/null +++ b/packages/openapi-v3/src/parameter-decorator.ts @@ -0,0 +1,408 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + isSchemaObject, + ParameterObject, + ParameterLocation, + ReferenceObject, + SchemaObject, +} from '@loopback/openapi-v3-types'; +import {MetadataInspector, ParameterDecoratorFactory} from '@loopback/context'; +import {getSchemaForParam} from './generate-schema'; +import {OAI3Keys} from './keys'; + +/** + * Describe an input parameter of a Controller method. + * + * `@param` must be applied to parameters. For example, + * ``` + * class MyController { + * @get('/') + * list( + * @param(offsetSpec) offset?: number, + * @param(pageSizeSpec) pageSize?: number, + * ) {} + * } + * ``` + * + * @param paramSpec Parameter specification. + */ +export function param(paramSpec: ParameterObject) { + return function(target: Object, member: string | symbol, index: number) { + paramSpec = paramSpec || {}; + // Get the design time method parameter metadata + const methodSig = MetadataInspector.getDesignTypeForMethod(target, member); + const paramTypes = (methodSig && methodSig.parameterTypes) || []; + + // Map design-time parameter type to the OpenAPI param type + + let paramType = paramTypes[index]; + + if (paramType) { + if ( + // generate schema if `paramSpec` doesn't have it + !paramSpec.schema || + // generate schema if `paramSpec` has `schema` but without `type` + (isSchemaObject(paramSpec.schema) && !paramSpec.schema.type) + ) { + // please note `getSchemaForParam` only adds `type` and `format` for `schema` + paramSpec.schema = getSchemaForParam(paramType, paramSpec.schema); + } + } + + if ( + paramSpec.schema && + isSchemaObject(paramSpec.schema) && + paramSpec.schema.type === 'array' + ) { + // 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 + }`, + ); + } + } + + ParameterDecoratorFactory.createDecorator( + OAI3Keys.PARAMETERS_KEY, + paramSpec, + )(target, member, index); + }; +} + +/** + * The `type` and `format` inferred by a common name of OpenAPI 3.0.0 data type + * reference link: + * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#data-types + */ +const builtinTypes = { + string: {type: 'string'}, + boolean: {type: 'boolean'}, + number: {type: 'number'}, + integer: {type: 'integer', format: 'int32'}, + long: {type: 'integer', format: 'int64'}, + float: {type: 'number', format: 'float'}, + double: {type: 'number', format: 'double'}, + byte: {type: 'string', format: 'byte'}, + binary: {type: 'string', format: 'binary'}, + date: {type: 'string', format: 'date'}, + dateTime: {type: 'string', format: 'date-time'}, + password: {type: 'string', format: 'password'}, +}; + +/** + * Shortcut parameter decorators + */ +export namespace param { + export const query = { + /** + * Define a parameter of "integer" type that's read from the query string. + * Usage: `@param.query.string('paramName')` + * + * @param name Parameter name. + */ + string: createParamShortcut('query', builtinTypes.string), + /** + * Define a parameter of "number" type that's read from the query string. + * Usage: `@param.query.number('paramName')` + * + * @param name Parameter name. + */ + number: createParamShortcut('query', builtinTypes.number), + /** + * Define a parameter of "boolean" type that's read from the query string. + * Usage: `@param.query.boolean('paramName')` + * + * @param name Parameter name. + */ + boolean: createParamShortcut('query', builtinTypes.boolean), + /** + * Define a parameter of "integer" type that's read from the query string. + * Usage: `@param.query.integer('paramName')` + * + * @param name Parameter name. + */ + integer: createParamShortcut('query', builtinTypes.integer), + /** + * Define a parameter of "long" type that's read from the query string. + * Usage: `@param.query.long('paramName')` + * + * @param name Parameter name. + */ + long: createParamShortcut('query', builtinTypes.long), + /** + * Define a parameter of "float" type that's read from the query string. + * Usage: `@param.query.float('paramName')` + * + * @param name Parameter name. + */ + float: createParamShortcut('query', builtinTypes.float), + /** + * Define a parameter of "double" type that's read from the query string. + * Usage: `@param.query.double('paramName')` + * + * @param name Parameter name. + */ + double: createParamShortcut('query', builtinTypes.double), + /** + * Define a parameter of "byte" type that's read from the query string. + * Usage: `@param.query.byte('paramName')` + * + * @param name Parameter name. + */ + byte: createParamShortcut('query', builtinTypes.byte), + /** + * Define a parameter of "binary" type that's read from the query string. + * Usage: `@param.query.binary('paramName')` + * + * @param name Parameter name. + */ + binary: createParamShortcut('query', builtinTypes.binary), + /** + * Define a parameter of "date" type that's read from the query string. + * Usage: `@param.query.date('paramName')` + * + * @param name Parameter name. + */ + date: createParamShortcut('query', builtinTypes.date), + /** + * Define a parameter of "dateTime" type that's read from the query string. + * Usage: `@param.query.dateTime('paramName')` + * + * @param name Parameter name. + */ + dateTime: createParamShortcut('query', builtinTypes.dateTime), + /** + * Define a parameter of "password" type that's read from the query string. + * Usage: `@param.query.password('paramName')` + * + * @param name Parameter name. + */ + password: createParamShortcut('query', builtinTypes.password), + }; + + export const header = { + /** + * Define a parameter of "string" type that's read from a request header. + * Usage: `@param.header.string('paramName')` + * + * @param name Parameter name, it must match the header name + * (e.g. `Content-Type`). + */ + string: createParamShortcut('header', builtinTypes.string), + /** + * Define a parameter of "number" type that's read from a request header. + * Usage: `@param.header.number('paramName')` + * + * @param name Parameter name, it must match the header name + * (e.g. `Content-Length`). + */ + number: createParamShortcut('header', builtinTypes.number), + /** + * Define a parameter of "boolean" type that's read from a request header. + * Usage: `@param.header.boolean('paramName')` + * + * @param name Parameter name, it must match the header name + * (e.g. `DNT` or `X-Do-Not-Track`). + */ + boolean: createParamShortcut('header', builtinTypes.boolean), + /** + * Define a parameter of "integer" type that's read from a request header. + * Usage: `@param.header.integer('paramName')` + * + * @param name Parameter name, it must match the header name + * (e.g. `Content-Length`). + */ + integer: createParamShortcut('header', builtinTypes.integer), + /** + * Define a parameter of "long" type that's read from a request header. + * Usage: `@param.header.long('paramName')` + * + * @param name Parameter name, it must match the header name + */ + long: createParamShortcut('header', builtinTypes.long), + /** + * Define a parameter of "float" type that's read from a request header. + * Usage: `@param.header.float('paramName')` + * + * @param name Parameter name, it must match the header name + */ + float: createParamShortcut('header', builtinTypes.float), + /** + * Define a parameter of "double" type that's read from a request header. + * Usage: `@param.header.double('paramName')` + * + * @param name Parameter name, it must match the header name + */ + double: createParamShortcut('header', builtinTypes.double), + /** + * Define a parameter of "byte" type that's read from a request header. + * Usage: `@param.header.byte('paramName')` + * + * @param name Parameter name, it must match the header name + */ + byte: createParamShortcut('header', builtinTypes.byte), + /** + * Define a parameter of "binary" type that's read from a request header. + * Usage: `@param.header.binary('paramName')` + * + * @param name Parameter name, it must match the header name + */ + binary: createParamShortcut('header', builtinTypes.binary), + /** + * Define a parameter of "date" type that's read from a request header. + * Usage: `@param.header.date('paramName')` + * + * @param name Parameter name, it must match the header name + */ + date: createParamShortcut('header', builtinTypes.date), + /** + * Define a parameter of "dateTime" type that's read from a request header. + * Usage: `@param.header.dateTime('paramName')` + * + * @param name Parameter name, it must match the header name + */ + dateTime: createParamShortcut('header', builtinTypes.dateTime), + /** + * Define a parameter of "password" type that's read from a request header. + * Usage: `@param.header.password('paramName')` + * + * @param name Parameter name, it must match the header name + */ + password: createParamShortcut('header', builtinTypes.password), + }; + export const path = { + /** + * Define a parameter of "string" type that's read from request path. + * Usage: `@param.path.string('paramName')` + * + * @param name Parameter name matching one of the placeholders in the path + */ + string: createParamShortcut('path', builtinTypes.string), + /** + * Define a parameter of "number" type that's read from request path. + * Usage: `@param.path.number('paramName')` + * + * @param name Parameter name matching one of the placeholders in the path + */ + number: createParamShortcut('path', builtinTypes.number), + /** + * Define a parameter of "boolean" type that's read from request path. + * Usage: `@param.path.boolean('paramName')` + * + * @param name Parameter name matching one of the placeholders in the path + */ + boolean: createParamShortcut('path', builtinTypes.boolean), + /** + * Define a parameter of "integer" type that's read from request path. + * Usage: `@param.path.integer('paramName')` + * + * @param name Parameter name matching one of the placeholders in the path + */ + integer: createParamShortcut('path', builtinTypes.integer), + /** + * Define a parameter of "long" type that's read from request path. + * Usage: `@param.path.long('paramName')` + * + * @param name Parameter name matching one of the placeholders in the path + */ + long: createParamShortcut('path', builtinTypes.long), + /** + * Define a parameter of "float" type that's read from request path. + * Usage: `@param.path.float('paramName')` + * + * @param name Parameter name matching one of the placeholders in the path + */ + float: createParamShortcut('path', builtinTypes.float), + /** + * Define a parameter of "double" type that's read from request path. + * Usage: `@param.path.double('paramName')` + * + * @param name Parameter name matching one of the placeholders in the path + */ + double: createParamShortcut('path', builtinTypes.double), + /** + * Define a parameter of "byte" type that's read from request path. + * Usage: `@param.path.byte('paramName')` + * + * @param name Parameter name matching one of the placeholders in the path + */ + byte: createParamShortcut('path', builtinTypes.byte), + /** + * Define a parameter of "binary" type that's read from request path. + * Usage: `@param.path.binary('paramName')` + * + * @param name Parameter name matching one of the placeholders in the path + */ + binary: createParamShortcut('path', builtinTypes.binary), + /** + * Define a parameter of "date" type that's read from request path. + * Usage: `@param.path.date('paramName')` + * + * @param name Parameter name matching one of the placeholders in the path + */ + date: createParamShortcut('path', builtinTypes.date), + /** + * Define a parameter of "dateTime" type that's read from request path. + * Usage: `@param.path.dateTime('paramName')` + * + * @param name Parameter name matching one of the placeholders in the path + */ + dateTime: createParamShortcut('path', builtinTypes.dateTime), + /** + * Define a parameter of "password" type that's read from request path. + * Usage: `@param.path.password('paramName')` + * + * @param name Parameter name matching one of the placeholders in the path + */ + password: createParamShortcut('path', builtinTypes.password), + }; + + /** + * Define a parameter of `array` type. + * + * @example + * ```ts + * export class MyController { + * @get('/greet') + * greet(@param.array('names', 'query', {type: '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: SchemaObject | ReferenceObject, + ) { + return param({ + name, + in: source, + schema: {type: 'array', items: itemSpec}, + }); + }; +} + +interface paramShortcutOptions { + type: string; + format?: string; +} + +function createParamShortcut( + source: ParameterLocation, + options: paramShortcutOptions, +) { + return (name: string) => { + return param({name, in: source, schema: {...options}}); + }; +} diff --git a/packages/openapi-v3/src/request-body-decorator.ts b/packages/openapi-v3/src/request-body-decorator.ts new file mode 100644 index 000000000000..cd128fcfc0d6 --- /dev/null +++ b/packages/openapi-v3/src/request-body-decorator.ts @@ -0,0 +1,148 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + RequestBodyObject, + SchemaObject, + ReferenceObject, +} from '@loopback/openapi-v3-types'; +import {MetadataInspector, ParameterDecoratorFactory} from '@loopback/context'; +import {getSchemaForRequestBody} from './generate-schema'; +import {OAI3Keys} from './keys'; +import * as _ from 'lodash'; +import {inspect} from 'util'; + +const debug = require('debug')('loopback:openapi3:metadata:requestbody'); +export const REQUEST_BODY_INDEX = 'x-parameter-index'; + +/** + * Describe the request body of a Controller method parameter. + * + * A typical OpenAPI requestBody spec contains property + * `description`, `required`, and `content`: + * + * ```ts + * requestBodySpec: { + * description: 'a user', + * required: true, + * content: { + * 'application/json': {...schemaSpec}, + * 'application/text': {...schemaSpec}, + * }, + * } + * ``` + * + * If the `content` object is not provided, this decorator sets it + * as `application/json` by default. + * If the `schema` object is not provided in a media type, this decorator + * generates it for you based on the argument's type. In this case, please + * make sure the argument type is a class decorated by @model from `@loopback/repository` + * + * The simplest usage is: + * + * ```ts + * class MyController { + * @post('/User') + * async create(@requestBody() user: User) {} + * } + * ``` + * + * or with properties other than `content` + * + * ```ts + * class MyController { + * @post('/User') + * async create(@requestBody({description: 'a user'}) user: User) {} + * } + * ``` + * + * or to be more complicated, with your customized media type + * + * ```ts + * class MyController { + * @post('/User') + * async create(@requestBody({ + * description: 'a user', + * // leave the schema as empty object, the decorator will generate it. + * content: {'application/text': {}} + * }) user: User) {} + * } + * ``` + * + * @param requestBodySpec The complete requestBody Object or partial of it. + * "partial" for allowing no `content` in spec, for example: + * ``` + * @requestBody({description: 'a request body'}) user: User + * ``` + */ +export function requestBody(requestBodySpec?: Partial) { + return function(target: Object, member: string | symbol, index: number) { + // Use 'application/json' as default content if `requestBody` is undefined + requestBodySpec = requestBodySpec || {content: {}}; + + if (_.isEmpty(requestBodySpec.content)) + requestBodySpec.content = {'application/json': {}}; + + // Get the design time method parameter metadata + const methodSig = MetadataInspector.getDesignTypeForMethod(target, member); + const paramTypes = (methodSig && methodSig.parameterTypes) || []; + + let paramType = paramTypes[index]; + let schema = getSchemaForRequestBody(paramType); + requestBodySpec.content = _.mapValues(requestBodySpec.content, c => { + c.schema = c.schema || schema; + return c; + }); + + // The default position for request body argument is 0 + // if not, add extension 'x-parameter-index' to specify the position + if (index !== 0) { + requestBodySpec[REQUEST_BODY_INDEX] = index; + } + + debug('requestBody member: ', member); + debug('requestBody index: ', index); + debug('requestBody spec: ', inspect(requestBodySpec, {depth: null})); + ParameterDecoratorFactory.createDecorator( + OAI3Keys.REQUEST_BODY_KEY, + requestBodySpec as RequestBodyObject, + )(target, member, index); + }; +} + +export namespace requestBody { + /** + * Define a requestBody of `array` type. + * + * @example + * ```ts + * export class MyController { + * @post('/greet') + * greet(@requestBody.array( + * {schema: {type: 'string'}}, + * {description: 'an array of names', required: false} + * ) names: string[]): string { + * return `Hello, ${names}`; + * } + * } + * ``` + * + * @param properties The requestBody properties other than `content` + * @param itemSpec the full item object + */ + export const array = function( + itemSpec: SchemaObject | ReferenceObject, + properties?: {description?: string; required?: boolean}, + ) { + return requestBody({ + ...properties, + content: { + 'application/json': { + schema: {type: 'array', items: itemSpec}, + }, + }, + }); + }; +} diff --git a/packages/openapi-v3/src/spec-builder/openapi-v3-spec-builder.ts b/packages/openapi-v3/src/spec-builder/openapi-v3-spec-builder.ts new file mode 100644 index 000000000000..d2bfdc771d82 --- /dev/null +++ b/packages/openapi-v3/src/spec-builder/openapi-v3-spec-builder.ts @@ -0,0 +1,192 @@ +// This is just a temporary file, in the next PR it will be moved to +// @loopback/openapi-spec-builder + +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/openapi-spec-builder +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import * as assert from 'assert'; +import { + ExtensionValue, + OpenApiSpec, + OperationObject, + ResponseObject, + ParameterObject, + createEmptyApiSpec, + RequestBodyObject, +} from '@loopback/openapi-v3-types'; + +/** + * Create a new instance of OpenApiSpecBuilder. + * + * @param basePath The base path on which the API is served. + */ +export function anOpenApiSpec() { + return new OpenApiSpecBuilder(); +} + +/** + * Create a new instance of OperationSpecBuilder. + */ +export function anOperationSpec() { + return new OperationSpecBuilder(); +} + +export interface Extendable { + [extension: string]: ExtensionValue; +} + +export class BuilderBase { + protected _spec: T; + + constructor(initialSpec: T) { + this._spec = initialSpec; + } + + /** + * Add a custom (extension) property to the spec object. + * + * @param key The property name starting with "x-". + * @param value The property value. + */ + withExtension(key: string, value: ExtensionValue): this { + assert( + key.startsWith('x-'), + `Invalid extension ${key}, extension keys must be prefixed with "x-"`, + ); + + this._spec[key] = value; + return this; + } + + /** + * Build the spec object. + */ + build(): T { + // TODO(bajtos): deep-clone + return this._spec; + } +} +/** + * A builder for creating OpenApiSpec documents. + */ +export class OpenApiSpecBuilder extends BuilderBase { + /** + * @param basePath The base path on which the API is served. + */ + constructor() { + super(createEmptyApiSpec()); + } + + /** + * Define a new OperationObject at the given path and verb (method). + * + * @param verb The HTTP verb. + * @param path The path relative to basePath. + * @param spec Additional specification of the operation. + */ + withOperation( + verb: string, + path: string, + spec: OperationObject | OperationSpecBuilder, + ): this { + if (spec instanceof OperationSpecBuilder) spec = spec.build(); + if (!this._spec.paths[path]) this._spec.paths[path] = {}; + this._spec.paths[path][verb] = spec; + return this; + } + + /** + * Define a new operation that returns a string response. + * + * @param verb The HTTP verb. + * @param path The path relative to basePath. + * @param operationName The name of the controller method implementing + * this operation (`x-operation-name` field). + */ + withOperationReturningString( + verb: string, + path: string, + operationName?: string, + ): this { + const spec = anOperationSpec().withStringResponse(200); + if (operationName) spec.withOperationName(operationName); + + return this.withOperation(verb, path, spec); + } +} + +/** + * A builder for creating OperationObject specifications. + */ +export class OperationSpecBuilder extends BuilderBase { + constructor() { + super({ + responses: {}, + }); + } + + /** + * Describe a response for a given HTTP status code. + * @param status HTTP status code or string "default" + * @param responseSpec Specification of the response + */ + withResponse(status: number | 'default', responseSpec: ResponseObject): this { + // OpenAPI spec uses string indices, i.e. 200 OK uses "200" as the index + this._spec.responses[status.toString()] = responseSpec; + return this; + } + + withStringResponse(status: number | 'default' = 200): this { + return this.withResponse(status, { + description: 'The string result.', + content: { + // TODO(janny) will change it to a default value + // after we figure out the plan for content type + '*/*': { + schema: {type: 'string'}, + }, + }, + }); + } + + /** + * Describe a parameter accepted by the operation. + * Note that parameters are positional in OpenAPI Spec, therefore + * the first call of `withParameter` defines the first parameter, + * the second call defines the second parameter, etc. + * @param parameterSpec + */ + withParameter(parameterSpec: ParameterObject): this { + if (!this._spec.parameters) this._spec.parameters = []; + this._spec.parameters.push(parameterSpec); + return this; + } + + withRequestBody(requestBodySpec: RequestBodyObject): this { + this._spec.requestBody = requestBodySpec; + return this; + } + + /** + * Define the operation name (controller method name). + * + * @param name The name of the controller method implementing this operation. + */ + withOperationName(name: string): this { + return this.withExtension('x-operation-name', name); + } + + /** + * Describe tags associated with the operation + * @param tags + */ + withTags(tags: string | string[]): this { + if (typeof tags === 'string') this._spec.tags = [tags]; + else { + this._spec.tags = tags; + } + return this; + } +} diff --git a/packages/openapi-v3/test/integration/controller-spec.test.ts b/packages/openapi-v3/test/integration/controller-spec.test.ts new file mode 100644 index 000000000000..aefb48cf7905 --- /dev/null +++ b/packages/openapi-v3/test/integration/controller-spec.test.ts @@ -0,0 +1,144 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {model, property} from '@loopback/repository'; +import {ParameterObject} from '@loopback/openapi-v3-types'; +import {param, requestBody, getControllerSpec, post} from '../../'; + +describe('controller spec', () => { + it('adds property schemas in components.schemas', () => { + @model() + class Bar { + @property() name: string; + } + + @model() + class Baz { + @property() name: string; + } + + @model() + class Foo { + @property() bar: Bar; + @property() baz: Baz; + } + + class FooController { + @post('/foo') + create( + @requestBody({description: 'a foo instance', required: true}) + foo: Foo, + ): void {} + } + + const expectedSpec = { + paths: { + '/foo': { + post: { + responses: {}, + requestBody: { + description: 'a foo instance', + required: true, + content: { + 'application/json': { + schema: {$ref: '#/components/schemas/Foo'}, + }, + }, + }, + 'x-operation-name': 'create', + }, + }, + }, + components: { + schemas: { + Bar: {title: 'Bar', properties: {name: {type: 'string'}}}, + Baz: {title: 'Baz', properties: {name: {type: 'string'}}}, + Foo: { + // guarantee `definition` is deleted + title: 'Foo', + properties: { + bar: {$ref: '#/components/schemas/Bar'}, + baz: {$ref: '#/components/schemas/Baz'}, + }, + }, + }, + }, + }; + + expect(getControllerSpec(FooController)).to.eql(expectedSpec); + }); + + it('does not produce nested definitions', () => { + const paramSpec: ParameterObject = { + name: 'foo', + in: 'query', + }; + + @model() + class Foo { + @property() bar: number; + } + + @model() + class MyParam { + @property() name: string; + @property() foo: Foo; + } + + class MyController { + @post('/foo') + foo(@param(paramSpec) body: MyParam) {} + } + + const components = getControllerSpec(MyController).components!; + const schemas = components.schemas; + + expect(schemas).to.have.keys('MyParam', 'Foo'); + expect(schemas!.MyParam).to.not.have.key('definitions'); + }); + it('infers no properties if no property metadata is present', () => { + const paramSpec: ParameterObject = { + name: 'foo', + in: 'query', + }; + + @model() + class MyParam { + name: string; + } + + class MyController { + @post('/foo') + foo(@param(paramSpec) foo: MyParam) {} + } + + const components = getControllerSpec(MyController).components!; + const schemas = components.schemas; + + expect(schemas).to.have.key('MyParam'); + expect(schemas!.MyParam).to.not.have.key('properties'); + }); + + it('does not infer definition if no class metadata is present', () => { + const paramSpec: ParameterObject = { + name: 'foo', + in: 'query', + }; + class MyParam { + @property() name: string; + } + class MyController { + @post('/foo') + foo(@param(paramSpec) foo: MyParam) {} + } + + const components = getControllerSpec(MyController).components!; + const schemas = components.schemas; + + expect(schemas).to.have.key('MyParam'); + expect(schemas!.MyParam).to.deepEqual({}); + }); +}); diff --git a/packages/openapi-v3/test/integration/operation-spec.test.ts b/packages/openapi-v3/test/integration/operation-spec.test.ts new file mode 100644 index 000000000000..6d5a15bf76be --- /dev/null +++ b/packages/openapi-v3/test/integration/operation-spec.test.ts @@ -0,0 +1,63 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {model, property} from '@loopback/repository'; +import {param, requestBody, getControllerSpec, post} from '../../'; + +describe('operation arguments', () => { + it('generate parameters and requestBody for operation', () => { + @model() + class User { + @property() name: string; + @property() password: number; + } + + class MyController { + @post('/users/{location}') + async createUser( + @param.query.string('type') type: string, + @param.header.string('token') token: string, + @param.path.string('location') location: string, + @requestBody() user: User, + ): Promise { + return; + } + } + + const expectedSpec = { + paths: { + '/users/{location}': { + post: { + responses: {}, + parameters: [ + {name: 'type', in: 'query', schema: {type: 'string'}}, + {name: 'token', in: 'header', schema: {type: 'string'}}, + {name: 'location', in: 'path', schema: {type: 'string'}}, + ], + requestBody: { + content: { + 'application/json': { + schema: {$ref: '#/components/schemas/User'}, + }, + }, + 'x-parameter-index': 3, + }, + 'x-operation-name': 'createUser', + }, + }, + }, + components: { + schemas: { + User: { + title: 'User', + properties: {name: {type: 'string'}, password: {type: 'number'}}, + }, + }, + }, + }; + expect(getControllerSpec(MyController)).to.eql(expectedSpec); + }); +}); diff --git a/packages/openapi-v3/test/unit/controller-spec/controller-decorators/operation-decorators.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/operation-decorators.test.ts new file mode 100644 index 000000000000..e783bbc65a09 --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/operation-decorators.test.ts @@ -0,0 +1,374 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + get, + api, + getControllerSpec, + operation, + post, + put, + patch, + del, + param, +} from '../../../..'; +import {expect} from '@loopback/testlab'; +import {anOpenApiSpec, anOperationSpec} from '../../../../'; + +describe('Routing metadata', () => { + it('returns spec defined via @api()', () => { + const expectedSpec = anOpenApiSpec() + .withOperationReturningString('get', '/greet', 'greet') + .build(); + + @api(expectedSpec) + class MyController { + greet() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec).to.eql(expectedSpec); + }); + + it('caches controller spec', () => { + const expectedSpec = anOpenApiSpec() + .withOperationReturningString('get', '/greet', 'greet') + .build(); + + @api(expectedSpec) + class MyController { + greet() { + return 'Hello world!'; + } + } + + const spec1 = getControllerSpec(MyController); + const spec2 = getControllerSpec(MyController); + expect(spec2).to.be.exactly(spec1); + }); + + it('returns spec defined via @get decorator', () => { + const operationSpec = anOperationSpec() + .withStringResponse() + .build(); + + class MyController { + @get('/greet', operationSpec) + greet() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec).to.eql({ + paths: { + '/greet': { + get: { + 'x-operation-name': 'greet', + ...operationSpec, + }, + }, + }, + }); + }); + + it('returns spec defined via @post decorator', () => { + const operationSpec = anOperationSpec() + .withStringResponse() + .build(); + + class MyController { + @post('/greeting', operationSpec) + createGreeting() {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec).to.eql({ + paths: { + '/greeting': { + post: { + 'x-operation-name': 'createGreeting', + ...operationSpec, + }, + }, + }, + }); + }); + + it('returns spec defined via @put decorator', () => { + const operationSpec = anOperationSpec() + .withStringResponse() + .build(); + + class MyController { + @put('/greeting', operationSpec) + updateGreeting() {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec).to.eql({ + paths: { + '/greeting': { + put: { + 'x-operation-name': 'updateGreeting', + ...operationSpec, + }, + }, + }, + }); + }); + + it('returns spec defined via @patch decorator', () => { + const operationSpec = anOperationSpec() + .withStringResponse() + .build(); + + class MyController { + @patch('/greeting', operationSpec) + patchGreeting() {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec).to.eql({ + paths: { + '/greeting': { + patch: { + 'x-operation-name': 'patchGreeting', + ...operationSpec, + }, + }, + }, + }); + }); + + it('returns spec defined via @del decorator', () => { + const operationSpec = anOperationSpec() + .withStringResponse() + .build(); + + class MyController { + @del('/greeting', operationSpec) + deleteGreeting() {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec).to.eql({ + paths: { + '/greeting': { + delete: { + 'x-operation-name': 'deleteGreeting', + ...operationSpec, + }, + }, + }, + }); + }); + + it('returns spec defined via @operation decorator', () => { + const operationSpec = anOperationSpec() + .withStringResponse() + .build(); + + class MyController { + @operation('post', '/greeting', operationSpec) + createGreeting() {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec).to.eql({ + paths: { + '/greeting': { + post: { + 'x-operation-name': 'createGreeting', + ...operationSpec, + }, + }, + }, + }); + }); + + it('returns default spec for @get with no spec', () => { + class MyController { + @get('/greet') + greet() {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet']['get']).to.eql({ + 'x-operation-name': 'greet', + responses: {}, + }); + }); + + it('returns default spec for @operation with no spec', () => { + class MyController { + @operation('post', '/greeting') + createGreeting() {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greeting']['post']).to.eql({ + 'x-operation-name': 'createGreeting', + responses: {}, + }); + }); + + it('honours specifications from inherited methods', () => { + const operationSpec = anOperationSpec() + .withStringResponse() + .build(); + + class Parent { + @get('/parent', operationSpec) + getParentName() { + return 'The Parent'; + } + } + + class Child extends Parent { + @get('/child', operationSpec) + getChildName() { + return 'The Child'; + } + } + + const actualSpec = getControllerSpec(Child); + + expect(actualSpec).to.eql({ + paths: { + '/parent': { + get: { + 'x-operation-name': 'getParentName', + ...operationSpec, + }, + }, + '/child': { + get: { + 'x-operation-name': 'getChildName', + ...operationSpec, + }, + }, + }, + }); + }); + + it('allows children to override parent REST endpoints', () => { + const operationSpec = anOperationSpec() + .withStringResponse() + .build(); + + class Parent { + @get('/name', operationSpec) + getParentName() { + return 'The Parent'; + } + } + + class Child extends Parent { + @get('/name', operationSpec) + getChildName() { + return 'The Child'; + } + } + + const actualSpec = getControllerSpec(Child); + + expect(actualSpec.paths['/name']['get']).to.have.property( + 'x-operation-name', + 'getChildName', + ); + }); + + it('allows children to override parent REST operations', () => { + const operationSpec = anOperationSpec() + .withStringResponse() + .build(); + + class Parent { + @get('/parent-name', operationSpec) + getName() { + return 'The Parent'; + } + } + + class Child extends Parent { + @get('/child-name', operationSpec) + getName() { + return 'The Child'; + } + } + + const childSpec = getControllerSpec(Child); + const parentSpec = getControllerSpec(Parent); + + expect(childSpec.paths['/child-name']['get']).to.have.property( + 'x-operation-name', + 'getName', + ); + + // The parent endpoint has been overridden + expect(childSpec.paths).to.not.have.property('/parent-name'); + + expect(parentSpec.paths['/parent-name']['get']).to.have.property( + 'x-operation-name', + 'getName', + ); + + // The parent endpoint should not be polluted + expect(parentSpec.paths).to.not.have.property('/child-name'); + }); + + it('allows children to override parent REST parameters', () => { + const operationSpec = anOperationSpec() + .withStringResponse() + .build(); + + class Parent { + @get('/greet', operationSpec) + greet(@param.query.string('msg') msg: string) { + return `Parent: ${msg}`; + } + } + + class Child extends Parent { + greet(@param.query.string('message') msg: string) { + return `Child: ${msg}`; + } + } + + const childSpec = getControllerSpec(Child); + const parentSpec = getControllerSpec(Parent); + + const childGreet = childSpec.paths['/greet']['get']; + expect(childGreet).to.have.property('x-operation-name', 'greet'); + + expect(childGreet.parameters).to.have.property('length', 1); + + expect(childGreet.parameters[0]).to.containEql({ + name: 'message', + in: 'query', + }); + + const parentGreet = parentSpec.paths['/greet']['get']; + expect(parentGreet).to.have.property('x-operation-name', 'greet'); + + expect(parentGreet.parameters).to.have.property('length', 1); + + expect(parentGreet.parameters[0]).to.containEql({ + name: 'msg', + in: 'query', + }); + }); +}); diff --git a/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param-header.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param-header.test.ts new file mode 100644 index 000000000000..d211bae7bbc7 --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param-header.test.ts @@ -0,0 +1,94 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {get, param, getControllerSpec} from '../../../../..'; +import {expect} from '@loopback/testlab'; + +describe('Routing metadata for parameters', () => { + describe('@param.header.string', () => { + it('defines a parameter with in:header type:string', () => { + class MyController { + @get('/greet') + greet(@param.header.string('name') name: string) {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ + { + name: 'name', + in: 'header', + schema: { + type: 'string', + }, + }, + ]); + }); + }); + + describe('@param.header.number', () => { + it('defines a parameter with in:header type:number', () => { + class MyController { + @get('/greet') + greet(@param.header.number('name') name: number) {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ + { + name: 'name', + in: 'header', + schema: { + type: 'number', + }, + }, + ]); + }); + }); + + describe('@param.header.integer', () => { + it('defines a parameter with in:header type:integer', () => { + class MyController { + @get('/greet') + greet(@param.header.integer('name') name: string) {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ + { + name: 'name', + in: 'header', + schema: { + type: 'integer', + format: 'int32', + }, + }, + ]); + }); + }); + + describe('@param.header.boolean', () => { + it('defines a parameter with in:header type:boolean', () => { + class MyController { + @get('/greet') + greet(@param.header.boolean('name') name: boolean) {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ + { + name: 'name', + in: 'header', + schema: { + type: 'boolean', + }, + }, + ]); + }); + }); +}); diff --git a/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param-path.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param-path.test.ts new file mode 100644 index 000000000000..439dc9d78e70 --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param-path.test.ts @@ -0,0 +1,94 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {get, param, getControllerSpec} from '../../../../..'; +import {expect} from '@loopback/testlab'; + +describe('Routing metadata for parameters', () => { + describe('@param.path.string', () => { + it('defines a parameter with in:path type:string', () => { + class MyController { + @get('/greet/{name}') + greet(@param.path.string('name') name: string) {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet/{name}']['get'].parameters).to.eql([ + { + name: 'name', + in: 'path', + schema: { + type: 'string', + }, + }, + ]); + }); + }); + + describe('@param.path.number', () => { + it('defines a parameter with in:path type:number', () => { + class MyController { + @get('/greet/{name}') + greet(@param.path.number('name') name: number) {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet/{name}']['get'].parameters).to.eql([ + { + name: 'name', + in: 'path', + schema: { + type: 'number', + }, + }, + ]); + }); + }); + + describe('@param.path.integer', () => { + it('defines a parameter with in:path type:integer', () => { + class MyController { + @get('/greet/{name}') + greet(@param.path.integer('name') name: number) {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet/{name}']['get'].parameters).to.eql([ + { + name: 'name', + in: 'path', + schema: { + type: 'integer', + format: 'int32', + }, + }, + ]); + }); + }); + + describe('@param.path.boolean', () => { + it('defines a parameter with in:path type:boolean', () => { + class MyController { + @get('/greet/{name}') + greet(@param.path.boolean('name') name: boolean) {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet/{name}']['get'].parameters).to.eql([ + { + name: 'name', + in: 'path', + schema: { + type: 'boolean', + }, + }, + ]); + }); + }); +}); diff --git a/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param-query.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param-query.test.ts new file mode 100644 index 000000000000..7248951ea8a4 --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param-query.test.ts @@ -0,0 +1,94 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {get, param, getControllerSpec} from '../../../../..'; +import {expect} from '@loopback/testlab'; + +describe('Routing metadata for parameters', () => { + describe('@param.query.string', () => { + it('defines a parameter with in:query type:string', () => { + class MyController { + @get('/greet') + greet(@param.query.string('name') name: string) {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ + { + name: 'name', + in: 'query', + schema: { + type: 'string', + }, + }, + ]); + }); + }); + + describe('@param.query.number', () => { + it('defines a parameter with in:query type:number', () => { + class MyController { + @get('/greet') + greet(@param.query.number('name') name: string) {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ + { + name: 'name', + in: 'query', + schema: { + type: 'number', + }, + }, + ]); + }); + }); + + describe('@param.query.integer', () => { + it('defines a parameter with in:query type:integer', () => { + class MyController { + @get('/greet') + greet(@param.query.integer('name') name: number) {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ + { + name: 'name', + in: 'query', + schema: { + type: 'integer', + format: 'int32', + }, + }, + ]); + }); + }); + + describe('@param.query.boolean', () => { + it('defines a parameter with in:query type:boolean', () => { + class MyController { + @get('/greet') + greet(@param.query.boolean('name') name: boolean) {} + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ + { + name: 'name', + in: 'query', + schema: { + type: 'boolean', + }, + }, + ]); + }); + }); +}); diff --git a/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts new file mode 100644 index 000000000000..3554912216a4 --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts @@ -0,0 +1,251 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + ParameterObject, + ResponsesObject, + OperationObject, +} from '@loopback/openapi-v3-types'; +import {param, get, patch, operation, getControllerSpec} from '../../../../../'; +import {anOperationSpec} from '../../../../../'; +import {expect} from '@loopback/testlab'; + +describe('Routing metadata for parameters', () => { + describe('@param', () => { + it('defines a new parameter', () => { + const paramSpec: ParameterObject = { + name: 'name', + schema: { + type: 'string', + }, + in: 'query', + }; + + class MyController { + @get('/greet') + greet(@param(paramSpec) name: string) {} + } + + const actualSpec = getControllerSpec(MyController); + + const expectedSpec = anOperationSpec() + .withOperationName('greet') + .withParameter(paramSpec) + .build(); + expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); + }); + + it('infers ts primitive types', () => { + class MyController { + @patch('/update/{id}') + update( + @param({ + name: 'id', + in: 'path', + }) + id: string, + @param({ + name: 'name', + in: 'query', + }) + name: string, + @param({ + name: 'age', + in: 'query', + }) + age: number, + @param({ + name: 'vip', + in: 'query', + }) + vip: boolean, + @param.array('tags', 'query', {type: 'string'}) + tags: string[], + @param({ + name: 'address', + in: 'query', + }) + address: object, + ) {} + } + + const actualSpec = getControllerSpec(MyController); + + const expectedSpec = anOperationSpec() + .withOperationName('update') + .withParameter({ + name: 'id', + schema: { + type: 'string', + }, + in: 'path', + }) + .withParameter({ + name: 'name', + schema: { + type: 'string', + }, + in: 'query', + }) + .withParameter({ + name: 'age', + schema: { + type: 'number', + }, + in: 'query', + }) + .withParameter({ + name: 'vip', + schema: { + type: 'boolean', + }, + in: 'query', + }) + .withParameter({ + name: 'tags', + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + in: 'query', + }) + .withParameter({ + name: 'address', + schema: { + type: 'object', + }, + in: 'query', + }) + .build(); + + expect(actualSpec.paths['/update/{id}']['patch']).to.eql(expectedSpec); + }); + + it('infers array type without explicit type', () => { + class MyController { + @get('/greet') + greet( + @param({ + name: 'names', + in: 'query', + schema: {items: {type: 'string'}}, + }) + names: string[], + ) {} + } + + const actualSpec = getControllerSpec(MyController); + + const expectedSpec = anOperationSpec() + .withOperationName('greet') + .withParameter({ + name: 'names', + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + in: 'query', + }) + .build(); + + expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); + }); + + it('reports error if an array parameter type is not Array', () => { + expect.throws( + () => { + // tslint:disable-next-line:no-unused-variable + class MyController { + @get('/greet') + greet( + @param.array('names', 'query', {type: 'string'}) + names: string, + ) {} + } + }, + Error, + `The parameter type is set to 'array' but the JavaScript type is String`, + ); + }); + + it('infers array parameter type with `any`', () => { + class MyController { + @get('/greet') + greet( + @param.array('names', 'query', {type: 'string'}) + names: /* tslint:disable-next-line:no-any */ + any, + ) {} + } + + const actualSpec = getControllerSpec(MyController); + + const expectedSpec = anOperationSpec() + .withOperationName('greet') + .withParameter({ + name: 'names', + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + in: 'query', + }) + .build(); + + expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); + }); + + it('adds to existing spec provided via @operation', () => { + const offsetSpec: ParameterObject = { + name: 'offset', + in: 'query', + schema: { + type: 'number', + }, + }; + + const pageSizeSpec: ParameterObject = { + name: 'pageSize', + in: 'query', + schema: { + type: 'number', + }, + }; + + const responses: ResponsesObject = { + 200: { + content: { + '*/*': { + schema: { + type: 'string', + }, + }, + }, + description: 'a string response', + }, + }; + + class MyController { + @operation('get', '/', {responses}) + list( + @param.query.number('offset') offset?: number, + @param.query.number('pageSize') pageSize?: number, + ) {} + } + + const apiSpec = getControllerSpec(MyController); + const opSpec: OperationObject = apiSpec.paths['/']['get']; + + expect(opSpec.responses).to.eql(responses); + expect(opSpec.parameters).to.eql([offsetSpec, pageSizeSpec]); + }); + }); +}); diff --git a/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/primative-types.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/primative-types.test.ts new file mode 100644 index 000000000000..ec642c4eeafc --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/primative-types.test.ts @@ -0,0 +1,76 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + ControllerSpec, + post, + requestBody, + getControllerSpec, +} from '../../../../../'; +import {ContentObject, SchemaObject} from '@loopback/openapi-v3-types'; +import {Class} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; + +describe('requestBody decorator', () => { + context('for a primitive type', () => { + let actualSpec: ControllerSpec; + let expectedContent: ContentObject; + + it('infers number', () => { + class MyController { + @post('/greeting') + greet(@requestBody() name: number) {} + } + assertRequestBodySpec({type: 'number'}, MyController); + }); + + it('infers string', () => { + class MyController { + @post('/greeting') + greet(@requestBody() name: string) {} + } + assertRequestBodySpec({type: 'string'}, MyController); + }); + + it('infers boolean', () => { + class MyController { + @post('/greeting') + greet(@requestBody() name: boolean) {} + } + assertRequestBodySpec({type: 'boolean'}, MyController); + }); + + it('infers object', () => { + class MyController { + @post('/greeting') + greet(@requestBody() name: object) {} + } + assertRequestBodySpec({type: 'object'}, MyController); + }); + + it('infers array', () => { + class MyController { + @post('/greeting') + greet(@requestBody() name: string[]) {} + } + assertRequestBodySpec({type: 'array'}, MyController); + }); + + function assertRequestBodySpec( + expectedSchemaSpec: SchemaObject, + controller: Class<{}>, + ) { + actualSpec = getControllerSpec(controller); + expectedContent = { + 'application/json': { + schema: expectedSchemaSpec, + }, + }; + expect(actualSpec.paths['/greeting']['post'].requestBody.content).to.eql( + expectedContent, + ); + } + }); +}); diff --git a/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/request-body.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/request-body.test.ts new file mode 100644 index 000000000000..fd8a33a12d6e --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/request-body.test.ts @@ -0,0 +1,106 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {post, requestBody, getControllerSpec} from '../../../../..'; +import {expect} from '@loopback/testlab'; +import {model, property} from '@loopback/repository'; + +describe('requestBody decorator', () => { + context('can build a correct "RequestBody" spec and', () => { + it('persists "description" and "required" into the generated schema', () => { + const requestSpec = { + description: 'A sample request body', + required: true, + }; + class MyController { + @post('/greeting') + greet(@requestBody(requestSpec) name: string) {} + } + + const requestBodySpec = getControllerSpec(MyController).paths[ + '/greeting' + ]['post'].requestBody; + expect(requestBodySpec).to.have.properties({ + description: 'A sample request body', + required: true, + }); + }); + + it('defaults content-type to "application/json"', () => { + const requestSpec = { + description: 'A sample request body', + required: true, + }; + class MyController { + @post('/greeting') + greet(@requestBody(requestSpec) name: string) {} + } + + const requestBodySpec = getControllerSpec(MyController).paths[ + '/greeting' + ]['post'].requestBody; + expect(requestBodySpec.content).to.have.key('application/json'); + }); + + it('infers request body with complex type', () => { + const expectedContent = { + 'application/text': { + schema: {$ref: '#/components/schemas/MyModel'}, + }, + }; + + @model() + class MyModel { + @property() name: string; + } + + class MyController { + @post('/MyModel') + createMyModel( + @requestBody({content: {'application/text': {}}}) + inst: MyModel, + ) {} + } + + const requestBodySpec = getControllerSpec(MyController).paths['/MyModel'][ + 'post' + ].requestBody; + expect(requestBodySpec.content).to.deepEqual(expectedContent); + }); + + it('schema in requestBody overrides the generated schema', () => { + const expectedContent = { + 'application/json': { + schema: {type: 'object'}, + }, + }; + + class MyModel {} + + class MyController { + @post('/MyModel') + createMyModel( + @requestBody({content: expectedContent}) + inst: MyModel, + ) {} + } + + const requestBodySpec = getControllerSpec(MyController).paths['/MyModel'][ + 'post' + ].requestBody; + expect(requestBodySpec.content).to.deepEqual(expectedContent); + }); + + it('reports error if more than one requestBody are found for the same method', () => { + class MyController { + @post('/greeting') + greet(@requestBody() name: string, @requestBody() foo: number) {} + } + expect(() => getControllerSpec(MyController)).to.throwError( + /An operation should only have one parameter decorated by @requestBody/, + ); + }); + }); +}); diff --git a/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/shortcut.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/shortcut.test.ts new file mode 100644 index 000000000000..56a891be94d6 --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/shortcut.test.ts @@ -0,0 +1,42 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {getControllerSpec, requestBody, post} from '../../../../../'; + +describe('requestBody decorator - shortcuts', () => { + context('array', () => { + it('generates the correct schema spec for an array argument', () => { + const description = 'an array of names'; + class MyController { + @post('/greeting') + greet( + @requestBody.array( + {type: 'string'}, + {description: description, required: false}, + ) + name: string[], + ) {} + } + + const actualSpec = getControllerSpec(MyController); + const expectedContent = { + 'application/json': { + schema: { + type: 'array', + items: {type: 'string'}, + }, + }, + }; + + const requestBodySpec = actualSpec.paths['/greeting']['post'].requestBody; + expect(requestBodySpec).to.have.properties({ + description: description, + required: false, + content: expectedContent, + }); + }); + }); +}); diff --git a/packages/openapi-v3/test/unit/controller-spec/json-to-schema-object.test.ts b/packages/openapi-v3/test/unit/controller-spec/json-to-schema-object.test.ts new file mode 100644 index 000000000000..cbc2cbdd79c0 --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/json-to-schema-object.test.ts @@ -0,0 +1,173 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3. +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {JsonDefinition} from '@loopback/repository-json-schema'; + +import {SchemaObject} from '@loopback/openapi-v3-types'; +import {jsonToSchemaObject} from '../../../index'; + +describe('jsonToSchemaObject', () => { + it('does nothing when given an empty object', () => { + expect({}).to.eql({}); + }); + const typeDef: JsonDefinition = {type: ['string', 'number']}; + const expectedType: SchemaObject = {type: 'string'}; + it('converts type', () => { + propertyConversionTest(typeDef, expectedType); + }); + + it('ignores non-compatible JSON schema properties', () => { + const nonCompatibleDef: JsonDefinition = { + anyOf: [], + oneOf: [], + additionalItems: { + anyOf: [], + }, + defaultProperties: [], + typeof: 'function', + }; + const expectedDef: SchemaObject = {}; + propertyConversionTest(nonCompatibleDef, expectedDef); + }); + + it('converts allOf', () => { + const allOfDef: JsonDefinition = { + allOf: [typeDef, typeDef], + }; + const expectedAllOf: SchemaObject = { + allOf: [expectedType, expectedType], + }; + propertyConversionTest(allOfDef, expectedAllOf); + }); + + it('converts definitions', () => { + const definitionsDef: JsonDefinition = { + definitions: {foo: typeDef, bar: typeDef}, + }; + const expectedDef: SchemaObject = { + definitions: {foo: expectedType, bar: expectedType}, + }; + propertyConversionTest(definitionsDef, expectedDef); + }); + + it('converts properties', () => { + const propertyDef: JsonDefinition = { + properties: { + foo: typeDef, + }, + }; + const expectedProperties: SchemaObject = { + properties: { + foo: expectedType, + }, + }; + propertyConversionTest(propertyDef, expectedProperties); + }); + + context('additionalProperties', () => { + it('is converted properly when the type is JsonDefinition', () => { + const additionalDef: JsonDefinition = { + additionalProperties: typeDef, + }; + const expectedAdditional: SchemaObject = { + additionalProperties: expectedType, + }; + propertyConversionTest(additionalDef, expectedAdditional); + }); + + it('is converted properly when it is "false"', () => { + const noAdditionalDef: JsonDefinition = { + additionalProperties: false, + }; + const expectedDef: SchemaObject = {}; + propertyConversionTest(noAdditionalDef, expectedDef); + }); + }); + + it('converts items', () => { + const itemsDef: JsonDefinition = { + type: 'array', + items: typeDef, + }; + const expectedItems: SchemaObject = { + type: 'array', + items: expectedType, + }; + propertyConversionTest(itemsDef, expectedItems); + }); + + context('enum', () => { + it('is converted properly when the type is primitive', () => { + const enumStringDef: JsonDefinition = { + enum: ['foo', 'bar'], + }; + const expectedStringDef: SchemaObject = { + enum: ['foo', 'bar'], + }; + propertyConversionTest(enumStringDef, expectedStringDef); + }); + + it('is converted properly when it is null', () => { + const enumNullDef: JsonDefinition = { + enum: [null, null], + }; + const expectedNullDef: JsonDefinition = { + enum: [null, null], + }; + propertyConversionTest(enumNullDef, expectedNullDef); + }); + + it('is converted properly when the type is complex', () => { + const enumCustomDef: JsonDefinition = { + enum: [typeDef, typeDef], + }; + const expectedCustomDef: SchemaObject = { + enum: [expectedType, expectedType], + }; + propertyConversionTest(enumCustomDef, expectedCustomDef); + }); + }); + + it('retains given properties in the conversion', () => { + const inputDef: JsonDefinition = { + title: 'foo', + type: 'object', + properties: { + foo: { + type: 'string', + }, + }, + default: 'Default string', + }; + const expectedDef: SchemaObject = { + title: 'foo', + type: 'object', + properties: { + foo: { + type: 'string', + }, + }, + default: 'Default string', + }; + expect(jsonToSchemaObject(inputDef)).to.eql(expectedDef); + }); + + it('errors if type is an array and items is missing', () => { + expect.throws( + () => { + jsonToSchemaObject({type: 'array'}); + }, + Error, + '"items" property must be present if "type" is an array', + ); + }); + + // Helper function to check conversion of JSON Schema properties + // to Swagger versions + function propertyConversionTest(property: Object, expected: Object) { + expect(jsonToSchemaObject(property)).to.deepEqual(expected); + } +}); diff --git a/packages/openapi-v3/tsconfig.build.json b/packages/openapi-v3/tsconfig.build.json new file mode 100644 index 000000000000..5d8dd36ccbc4 --- /dev/null +++ b/packages/openapi-v3/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../build/config/tsconfig.common.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["index.ts", "src", "test"] +}