diff --git a/.vscode/launch.json b/.vscode/launch.json index c6adda413542..7715a6bc9e35 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,6 +18,22 @@ "0" ] }, + { + "type": "node", + "request": "launch", + "name": "Debug Current Test File", + "program": "${workspaceRoot}/packages/build/node_modules/.bin/_mocha", + "cwd": "${workspaceRoot}", + "autoAttachChildProcesses": true, + "args": [ + "--config", + "${workspaceRoot}/packages/build/config/.mocharc.json", + "-t", + "0", + "$(node ${workspaceRoot}/packages/build/bin/get-dist-file ${file})" + ], + "disableOptimisticBPs": true + }, { "type": "node", "request": "attach", diff --git a/docs/package-lock.json b/docs/package-lock.json index 27aa9ebd47a9..3b8b529a9d9d 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -19,6 +19,12 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==" }, + "http-status": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/http-status/-/http-status-1.4.2.tgz", + "integrity": "sha512-mBnIohUwRw9NyXMEMMv8/GANnzEYUj0Y8d3uL01zDWFkxUjYyZ6rgCaAI2zZ1Wb34Oqtbx/nFZolPRDc8Xlm5A==", + "dev": true + }, "jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", diff --git a/package-lock.json b/package-lock.json index 42721cdc832c..a584b04e77cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3713,6 +3713,12 @@ "flat-cache": "^2.0.1" } }, + "file-type": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-11.1.0.tgz", + "integrity": "sha512-rM0UO7Qm9K7TWTtA6AShI/t7H5BPjDeGVDaNyg9BjHAj3PysKy7+8C8D137R88jnR3rFJZQB/tFgydl5sN5m7g==", + "dev": true + }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", @@ -5104,6 +5110,12 @@ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true }, + "is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=", + "dev": true + }, "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -6038,6 +6050,58 @@ "mimic-fn": "^1.0.0" } }, + "open": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-6.4.0.tgz", + "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", + "dev": true, + "requires": { + "is-wsl": "^1.1.0" + } + }, + "open-cli": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/open-cli/-/open-cli-5.0.0.tgz", + "integrity": "sha512-Y2KQDS6NqNtk+PSXzSgwH41vTDMRndwFgVWsfgMhXv7lNe1cImLCe19Vo8oKwMsL7WeNsGmmbX7Ml74Ydj61Cg==", + "dev": true, + "requires": { + "file-type": "^11.0.0", + "get-stdin": "^7.0.0", + "meow": "^5.0.0", + "open": "^6.3.0", + "temp-write": "^4.0.0" + }, + "dependencies": { + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true + }, + "make-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz", + "integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "temp-write": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/temp-write/-/temp-write-4.0.0.tgz", + "integrity": "sha512-HIeWmj77uOOHb0QX7siN3OtwV3CTntquin6TNVg6SHOqCP3hYKmox90eeFOGaY1MqJ9WYDDjkyZrW6qS5AWpbw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.15", + "is-stream": "^2.0.0", + "make-dir": "^3.0.0", + "temp-dir": "^1.0.0", + "uuid": "^3.3.2" + } + } + } + }, "opencollective-postinstall": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz", diff --git a/package.json b/package.json index 28e983b7b598..03cb3b6837d9 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "fs-extra": "^8.1.0", "husky": "^3.1.0", "lerna": "^3.20.2", + "open-cli": "^5.0.0", "typescript": "~3.7.4" }, "scripts": { @@ -42,7 +43,7 @@ "tsdocs": "lerna run --scope @loopback/tsdocs build:tsdocs", "coverage:ci": "node packages/build/bin/run-nyc report --reporter=text-lcov | coveralls", "precoverage": "npm test", - "coverage": "open coverage/index.html", + "coverage": "open-cli coverage/index.html", "lint": "npm run prettier:check && npm run eslint && node bin/check-package-locks", "lint:fix": "npm run eslint:fix && npm run prettier:fix", "eslint": "node packages/build/bin/run-eslint --report-unused-disable-directives --cache .", diff --git a/packages/build/bin/get-dist-file.js b/packages/build/bin/get-dist-file.js new file mode 100644 index 000000000000..418c4a408fff --- /dev/null +++ b/packages/build/bin/get-dist-file.js @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/build +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/* +======== +This is used in the launch.json to enable you to debug a test file written in +typescript. This function attempts to convert the passed typescript file to +the best-gust output javascript file. + +It walks up the filesystem from the current file, stops at package.json, and +looks in `dist` + +Ideally, we could use the typescript compiler and tsconfig.json to get the +explicit output file instead of trying to guess it. + +Ex: +```jsonc +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug Current Test File", + "program": "${workspaceRoot}/packages/build/node_modules/.bin/_mocha", + "cwd": "${workspaceRoot}", + "autoAttachChildProcesses": true, + "args": [ + "--config", + "${workspaceRoot}/packages/build/config/.mocharc.json", + "-t", + "0", + "$(node ${workspaceRoot}/packages/build/bin/get-dist-file ${file})" + ], + "disableOptimisticBPs": true + } + ] +} +``` + +For your personal projects, you can sub directlry from loopback: +``` +"$(node ${workspaceRoot}/node_modules/@loopback/build/bin/get-dist-file ${file})" +``` +You still have to compile the package/project first. +======== +*/ + +'use strict'; +const path = require('path'); +const fs = require('fs'); + +function findDistFile(filename) { + const absolutePath = path.resolve(filename); + let currentDir = path.dirname(absolutePath); + let isPackageRoot = fs.existsSync(path.resolve(currentDir, 'package.json')); + while (!isPackageRoot) { + currentDir = path.join(currentDir, '..'); + isPackageRoot = fs.existsSync(path.resolve(currentDir, 'package.json')); + } + const base = path.resolve(currentDir); + const relative = path.relative(currentDir, absolutePath); + const resultPath = relative + .replace(/^src\//, 'dist/') + .replace(/\.ts$/, '.js'); + return path.resolve(base, resultPath); +} + +module.exports = findDistFile; +if (require.main === module) { + process.stdout.write(findDistFile(process.argv.splice(-1)[0])); +} diff --git a/packages/eslint-config/package-lock.json b/packages/eslint-config/package-lock.json new file mode 100644 index 000000000000..fca86ce859a8 --- /dev/null +++ b/packages/eslint-config/package-lock.json @@ -0,0 +1,14 @@ +{ + "name": "@loopback/eslint-config", + "version": "5.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "http-status": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/http-status/-/http-status-1.4.2.tgz", + "integrity": "sha512-mBnIohUwRw9NyXMEMMv8/GANnzEYUj0Y8d3uL01zDWFkxUjYyZ6rgCaAI2zZ1Wb34Oqtbx/nFZolPRDc8Xlm5A==", + "dev": true + } + } +} diff --git a/packages/metadata/src/__tests__/unit/decorator-factory.unit.ts b/packages/metadata/src/__tests__/unit/decorator-factory.unit.ts index d942761399eb..a912d9c7f378 100644 --- a/packages/metadata/src/__tests__/unit/decorator-factory.unit.ts +++ b/packages/metadata/src/__tests__/unit/decorator-factory.unit.ts @@ -8,6 +8,7 @@ import { ClassDecoratorFactory, DecoratorFactory, MethodDecoratorFactory, + MethodMultiDecoratorFactory, MethodParameterDecoratorFactory, ParameterDecoratorFactory, PropertyDecoratorFactory, @@ -523,6 +524,193 @@ describe('MethodDecoratorFactory for static methods', () => { }); }); +describe('MethodMultiDecoratorFactory', () => { + function methodMultiArrayDecorator(spec: object | object[]): MethodDecorator { + if (Array.isArray(spec)) { + return MethodMultiDecoratorFactory.createDecorator('test', spec); + } else { + return MethodMultiDecoratorFactory.createDecorator('test', [spec]); + } + } + + function methodMultiDecorator(spec: object): MethodDecorator { + return MethodMultiDecoratorFactory.createDecorator('test', spec); + } + + class BaseController { + @methodMultiArrayDecorator({x: 1}) + public myMethod() {} + + @methodMultiArrayDecorator({foo: 1}) + @methodMultiArrayDecorator({foo: 2}) + @methodMultiArrayDecorator([{foo: 3}, {foo: 4}]) + public multiMethod() {} + + @methodMultiDecorator({a: 'a'}) + @methodMultiDecorator({b: 'b'}) + public checkDecorator() {} + } + + class SubController extends BaseController { + @methodMultiArrayDecorator({y: 2}) + public myMethod() {} + + @methodMultiArrayDecorator({bar: 1}) + @methodMultiArrayDecorator([{bar: 2}, {bar: 3}]) + public multiMethod() {} + } + + describe('single-decorator methods', () => { + it('applies metadata to a method', () => { + const meta = Reflector.getOwnMetadata('test', BaseController.prototype); + expect(meta.myMethod).to.eql([{x: 1}]); + }); + + it('merges with base method metadata', () => { + const meta = Reflector.getOwnMetadata('test', SubController.prototype); + expect(meta.myMethod).to.eql([{x: 1}, {y: 2}]); + }); + + it('does not mutate base method metadata', () => { + const meta = Reflector.getOwnMetadata('test', BaseController.prototype); + expect(meta.myMethod).to.eql([{x: 1}]); + }); + }); + + describe('multi-decorator methods', () => { + it('applies to non-array decorator creation', () => { + const meta = Reflector.getOwnMetadata('test', BaseController.prototype); + expect(meta.checkDecorator).to.containDeep([{a: 'a'}, {b: 'b'}]); + }); + + it('applies metadata to a method', () => { + const meta = Reflector.getOwnMetadata('test', BaseController.prototype); + expect(meta.multiMethod).to.containDeep([ + {foo: 4}, + {foo: 3}, + {foo: 2}, + {foo: 1}, + ]); + }); + + it('merges with base method metadata', () => { + const meta = Reflector.getOwnMetadata('test', SubController.prototype); + expect(meta.multiMethod).to.containDeep([ + {foo: 4}, + {foo: 3}, + {foo: 2}, + {foo: 1}, + {bar: 3}, + {bar: 2}, + {bar: 1}, + ]); + }); + + it('does not mutate base method metadata', () => { + const meta = Reflector.getOwnMetadata('test', BaseController.prototype); + expect(meta.multiMethod).to.containDeep([ + {foo: 1}, + {foo: 2}, + {foo: 3}, + {foo: 4}, + ]); + }); + }); +}); +describe('MethodMultiDecoratorFactory for static methods', () => { + function methodMultiArrayDecorator(spec: object | object[]): MethodDecorator { + if (Array.isArray(spec)) { + return MethodMultiDecoratorFactory.createDecorator('test', spec); + } else { + return MethodMultiDecoratorFactory.createDecorator('test', [spec]); + } + } + + function methodMultiDecorator(spec: object): MethodDecorator { + return MethodMultiDecoratorFactory.createDecorator('test', spec); + } + + class BaseController { + @methodMultiArrayDecorator({x: 1}) + static myMethod() {} + + @methodMultiArrayDecorator({foo: 1}) + @methodMultiArrayDecorator({foo: 2}) + @methodMultiArrayDecorator([{foo: 3}, {foo: 4}]) + static multiMethod() {} + + @methodMultiDecorator({a: 'a'}) + @methodMultiDecorator({b: 'b'}) + static checkDecorator() {} + } + + class SubController extends BaseController { + @methodMultiArrayDecorator({y: 2}) + static myMethod() {} + + @methodMultiArrayDecorator({bar: 1}) + @methodMultiArrayDecorator([{bar: 2}, {bar: 3}]) + static multiMethod() {} + } + + describe('single-decorator methods', () => { + it('applies metadata to a method', () => { + const meta = Reflector.getOwnMetadata('test', BaseController); + expect(meta.myMethod).to.eql([{x: 1}]); + }); + + it('merges with base method metadata', () => { + const meta = Reflector.getOwnMetadata('test', SubController); + expect(meta.myMethod).to.eql([{x: 1}, {y: 2}]); + }); + + it('does not mutate base method metadata', () => { + const meta = Reflector.getOwnMetadata('test', BaseController); + expect(meta.myMethod).to.eql([{x: 1}]); + }); + }); + + describe('multi-decorator methods', () => { + it('applies metadata to a method', () => { + const meta = Reflector.getOwnMetadata('test', BaseController); + expect(meta.multiMethod).to.containDeep([ + {foo: 4}, + {foo: 3}, + {foo: 2}, + {foo: 1}, + ]); + }); + + it('applies to non-array decorator creation', () => { + const meta = Reflector.getOwnMetadata('test', BaseController); + expect(meta.checkDecorator).to.containDeep([{a: 'a'}, {b: 'b'}]); + }); + + it('merges with base method metadata', () => { + const meta = Reflector.getOwnMetadata('test', SubController); + expect(meta.multiMethod).to.containDeep([ + {foo: 4}, + {foo: 3}, + {foo: 2}, + {foo: 1}, + {bar: 3}, + {bar: 2}, + {bar: 1}, + ]); + }); + + it('does not mutate base method metadata', () => { + const meta = Reflector.getOwnMetadata('test', BaseController); + expect(meta.multiMethod).to.containDeep([ + {foo: 1}, + {foo: 2}, + {foo: 3}, + {foo: 4}, + ]); + }); + }); +}); + describe('ParameterDecoratorFactory', () => { /** * Define `@parameterDecorator(spec)` diff --git a/packages/metadata/src/decorator-factory.ts b/packages/metadata/src/decorator-factory.ts index 3c8a10dbbc14..10888c621812 100644 --- a/packages/metadata/src/decorator-factory.ts +++ b/packages/metadata/src/decorator-factory.ts @@ -789,3 +789,78 @@ export class MethodParameterDecoratorFactory extends DecoratorFactory< ); } } + +/** + * Factory for an append-array of method-level decorators + * The @response metadata for a method is an array. + * Each item in the array should be a single value, containing + * a response code and a single spec or Model. This should allow: + * ```ts + * @response(200, MyFirstModel) + * @response(403, [NotAuthorizedReasonOne, NotAuthorizedReasonTwo]) + * @response(404, NotFoundOne) + * @response(404, NotFoundTwo) + * @response(409, {schema: {}}) + * public async myMethod() {} + * ``` + * + * In the case that a ResponseObject is passed, it becomes the + * default for description/content, and if possible, further Models are + * incorporated as a `oneOf: []` array. + * + * In the case that a ReferenceObject is passed, it and it alone is used, since + * references can be external and we cannot `oneOf` their content. + * + * The factory creates and updates an array of items T[], and the getter + * provides the values as that array. + */ +export class MethodMultiDecoratorFactory extends MethodDecoratorFactory< + T[] +> { + protected mergeWithInherited( + inheritedMetadata: MetadataMap, + target: Object, + methodName?: string, + ) { + inheritedMetadata = inheritedMetadata || {}; + + inheritedMetadata[methodName!] = this._mergeArray( + inheritedMetadata[methodName!], + this.withTarget(this.spec, target), + ); + + return inheritedMetadata; + } + + protected mergeWithOwn( + ownMetadata: MetadataMap, + target: Object, + methodName?: string, + methodDescriptor?: TypedPropertyDescriptor | number, + ) { + ownMetadata = ownMetadata || {}; + ownMetadata[methodName!] = this._mergeArray( + ownMetadata[methodName!], + this.withTarget(this.spec, target), + ); + return ownMetadata; + } + + private _mergeArray(result: T[], methodMeta: T | T[]) { + if (!result) { + if (Array.isArray(methodMeta)) { + result = methodMeta; + } else { + result = [methodMeta]; + } + } else { + if (Array.isArray(methodMeta)) { + result.push(...methodMeta); + } else { + result.push(methodMeta); + } + } + + return result; + } +} diff --git a/packages/openapi-v3/package-lock.json b/packages/openapi-v3/package-lock.json index b105293a4eef..9d470e4f68f1 100644 --- a/packages/openapi-v3/package-lock.json +++ b/packages/openapi-v3/package-lock.json @@ -97,6 +97,11 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" }, + "http-status": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/http-status/-/http-status-1.4.2.tgz", + "integrity": "sha512-mBnIohUwRw9NyXMEMMv8/GANnzEYUj0Y8d3uL01zDWFkxUjYyZ6rgCaAI2zZ1Wb34Oqtbx/nFZolPRDc8Xlm5A==" + }, "is-arguments": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz", diff --git a/packages/openapi-v3/package.json b/packages/openapi-v3/package.json index c0c4eb4b8a57..493c01e70a8e 100644 --- a/packages/openapi-v3/package.json +++ b/packages/openapi-v3/package.json @@ -9,6 +9,7 @@ "@loopback/core": "^1.12.1", "@loopback/repository-json-schema": "^1.11.4", "debug": "^4.1.1", + "http-status": "^1.4.2", "json-merge-patch": "^0.2.3", "lodash": "^4.17.15", "openapi3-ts": "^1.3.0" diff --git a/packages/openapi-v3/src/__tests__/unit/decorators/deprecated.decorator.unit.ts b/packages/openapi-v3/src/__tests__/unit/decorators/deprecated.decorator.unit.ts new file mode 100644 index 000000000000..23dbcfae0415 --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/decorators/deprecated.decorator.unit.ts @@ -0,0 +1,111 @@ +// Copyright IBM Corp. 2019. 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 {anOpenApiSpec} from '@loopback/openapi-spec-builder'; +import {expect} from '@loopback/testlab'; +import {getControllerSpec} from '../../..'; +import {api, deprecated, get} from '../../../decorators'; + +describe('deprecation decorator', () => { + it('Returns a spec with all the items decorated from the class level', () => { + const expectedSpec = anOpenApiSpec() + .withOperationReturningString('get', '/greet', 'greet') + .withOperationReturningString('get', '/echo', 'echo') + .build(); + + @api(expectedSpec) + @deprecated() + class MyController { + greet() { + return 'Hello world!'; + } + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.deprecated).to.eql(true); + expect(actualSpec.paths['/echo'].get.deprecated).to.eql(true); + }); + + it('Returns a spec where only one method is deprecated', () => { + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + @deprecated() + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.deprecated).to.be.undefined(); + expect(actualSpec.paths['/echo'].get.deprecated).to.eql(true); + }); + + it('Allows a method to override the deprecation of a class', () => { + @deprecated() + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + echo() { + return 'Hello world!'; + } + + @get('/yell') + @deprecated(false) + yell() { + return 'HELLO WORLD!'; + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.deprecated).to.eql(true); + expect(actualSpec.paths['/echo'].get.deprecated).to.eql(true); + expect(actualSpec.paths['/yell'].get.deprecated).to.be.undefined(); + }); + + it('Allows a class to not be decorated with @deprecated at all', () => { + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.deprecated).to.be.undefined(); + expect(actualSpec.paths['/echo'].get.deprecated).to.be.undefined(); + }); + + it('Does not allow a member variable to be decorated', () => { + const shouldThrow = () => { + class MyController { + @deprecated() + public foo: string; + + @get('/greet') + greet() {} + } + + return getControllerSpec(MyController); + }; + + expect(shouldThrow).to.throw(/^\@deprecated cannot be used on a property:/); + }); +}); diff --git a/packages/openapi-v3/src/__tests__/unit/decorators/response.decorator.unit.ts b/packages/openapi-v3/src/__tests__/unit/decorators/response.decorator.unit.ts new file mode 100644 index 000000000000..659d09d1e401 --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/decorators/response.decorator.unit.ts @@ -0,0 +1,216 @@ +// Copyright IBM Corp. 2019. 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 {Model, model, property} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; +import * as httpStatus from 'http-status'; +import {ResponseObject} from 'openapi3-ts'; +import {getControllerSpec} from '../../..'; +import {get, response} from '../../../decorators'; + +describe('@response decorator', () => { + it('allows a class to not be decorated with @response at all', () => { + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses['200'].description).to.eql( + 'Return value of MyController.greet', + ); + }); + + context('with response models', () => { + @model() + class SuccessModel extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'ok'}) + message: string; + } + + @model() + class FooError extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'foo'}) + foo: string; + } + + @model() + class BarError extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'bar'}) + bar: string; + } + + const successSchema: ResponseObject = { + description: httpStatus['200_MESSAGE'], + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SuccessModel', + }, + }, + }, + }; + + const fooBarSchema: ResponseObject = { + description: httpStatus['404_MESSAGE'], + content: { + 'application/json': { + schema: { + anyOf: [ + {$ref: '#/components/schemas/BarError'}, + {$ref: '#/components/schemas/FooError'}, + ], + }, + }, + }, + }; + + it('supports a single @response decorator', () => { + class MyController { + @get('/greet') + @response(200, SuccessModel) + greet() { + return new SuccessModel({message: 'Hello, world'}); + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[200]).to.eql( + successSchema, + ); + expect( + actualSpec.components?.schemas?.SuccessModel, + ).to.not.be.undefined(); + }); + + it('supports multiple @response decorators on a method', () => { + class MyController { + @get('/greet') + @response(200, SuccessModel) + @response(404, FooError) + @response(404, BarError) + greet() { + throw new FooError({foo: 'bar'}); + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[404]).to.eql( + fooBarSchema, + ); + expect(actualSpec.components?.schemas?.FooError).to.not.be.undefined(); + expect(actualSpec.components?.schemas?.BarError).to.not.be.undefined(); + expect( + actualSpec.components?.schemas?.SuccessModel, + ).to.not.be.undefined(); + }); + it('supports multiple @response decorators with an array of models', () => { + class MyController { + @get('/greet') + @response(200, SuccessModel) + @response(404, [BarError, FooError]) + greet() { + throw new BarError({bar: 'baz'}); + } + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet'].get.responses[404]).to.eql( + fooBarSchema, + ); + expect(actualSpec.components?.schemas?.FooError).to.not.be.undefined(); + expect(actualSpec.components?.schemas?.BarError).to.not.be.undefined(); + expect( + actualSpec.components?.schemas?.SuccessModel, + ).to.not.be.undefined(); + }); + + context('with complex responses', () => { + const FIRST_SCHEMA = { + type: 'object', + properties: { + x: { + type: 'int', + default: 1, + }, + y: { + type: 'string', + default: '2', + }, + }, + }; + + const SECOND_SCHEMA = { + type: 'string', + format: 'base64', + }; + + class MyController { + @get('/greet', { + responses: { + 200: { + description: 'Unknown', + content: { + 'application/json': {schema: FIRST_SCHEMA}, + }, + }, + }, + }) + @response(200, SECOND_SCHEMA, {contentType: 'application/pdf'}) + @response(200, SuccessModel, {contentType: 'application/jsonc'}) + @response(404, [FooError, BarError], {contentType: 'application/jsonc'}) + greet() { + return new SuccessModel({message: 'Hello, world!'}); + } + } + + const actualSpec = getControllerSpec(MyController); + expect( + actualSpec.paths['/greet'].get.responses[200].content[ + 'application/json' + ], + ).to.not.be.undefined(); + expect( + actualSpec.paths['/greet'].get.responses[200].content[ + 'application/pdf' + ], + ).to.not.be.undefined(); + + expect( + actualSpec.paths['/greet'].get.responses[200].content[ + 'application/jsonc' + ], + ).to.not.be.undefined(); + expect( + actualSpec.paths['/greet'].get.responses[200].content[ + 'application/json' + ].schema, + ).to.eql(FIRST_SCHEMA); + + expect( + actualSpec.paths['/greet'].get.responses[200].content['application/pdf'] + .schema, + ).to.eql(SECOND_SCHEMA); + + expect( + actualSpec.paths['/greet'].get.responses[200].content[ + 'application/jsonc' + ].schema, + ).to.eql({$ref: '#/components/schemas/SuccessModel'}); + }); + }); +}); diff --git a/packages/openapi-v3/src/__tests__/unit/decorators/tags.decorator.unit.ts b/packages/openapi-v3/src/__tests__/unit/decorators/tags.decorator.unit.ts new file mode 100644 index 000000000000..8f1708c90681 --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/decorators/tags.decorator.unit.ts @@ -0,0 +1,102 @@ +// Copyright IBM Corp. 2019. 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} from '../../..'; +import {get, tags} from '../../../decorators'; + +describe('@tags decorator', () => { + it('Allows a class to not be decorated with @tags at all', () => { + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.tags).to.be.undefined(); + }); + + it('Allows a class to decorate methods with @tags', () => { + @tags(['Foo', 'Bar']) + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.tags).to.eql(['Foo', 'Bar']); + expect(actualSpec.paths['/echo'].get.tags).to.eql(['Foo', 'Bar']); + }); + + it('Allows method @tags to override controller @tags', () => { + @tags(['Foo', 'Bar']) + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + @tags(['Baz']) + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.tags).to.eql(['Foo', 'Bar']); + expect(actualSpec.paths['/echo'].get.tags).to.eql(['Baz']); + }); + + it('Allows @tags with options to append', () => { + @tags(['Foo']) + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + @tags(['Bar'], {append: true}) + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.tags).to.eql(['Foo']); + expect(actualSpec.paths['/echo'].get.tags).to.eql(['Foo', 'Bar']); + }); + + it('Does not allow a member variable to be decorated', () => { + const shouldThrow = () => { + class MyController { + @tags(['foo', 'bar']) + public foo: string; + + @get('/greet') + greet() {} + } + + return getControllerSpec(MyController); + }; + + expect(shouldThrow).to.throw(/^\@tags cannot be used on a property:/); + }); +}); diff --git a/packages/openapi-v3/src/__tests__/unit/decorators/x-ts-type.unit.ts b/packages/openapi-v3/src/__tests__/unit/decorators/x-ts-type.unit.ts new file mode 100644 index 000000000000..4d978a10940d --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/decorators/x-ts-type.unit.ts @@ -0,0 +1,226 @@ +// Copyright IBM Corp. 2019. 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 {Model, model, property} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; +import {RequestBodyObject, ResponseObject} from 'openapi3-ts'; +import {getControllerSpec} from '../../..'; +import {get, post, requestBody} from '../../../decorators'; + +describe('x-ts-type is converted in the right places', () => { + // setup the models for use + @model() + class TestRequest extends Model { + @property({default: 1}) + value: number; + } + @model() + class SuccessModel extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'ok'}) + message: string; + } + + @model() + class FooError extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'foo'}) + foo: string; + } + + @model() + class BarError extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'bar'}) + bar: string; + } + + const testRequestSchema: RequestBodyObject = { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TestRequest', + }, + }, + }, + }; + const successSchema: ResponseObject = { + description: 'Success', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SuccessModel', + }, + }, + }, + }; + + const notSchema: ResponseObject = { + description: 'Failure', + content: { + 'application/json': { + schema: { + not: {$ref: '#/components/schemas/BarError'}, + }, + }, + }, + }; + const fooBarSchema = (k: 'anyOf' | 'allOf' | 'oneOf'): ResponseObject => ({ + description: 'Failure', + content: { + 'application/json': { + schema: { + [k]: [ + {$ref: '#/components/schemas/FooError'}, + {$ref: '#/components/schemas/BarError'}, + ], + }, + }, + }, + }); + + it('Allows a simple request schema', () => { + class MyController { + @post('/greet') + greet(@requestBody() body: TestRequest) { + return 'Hello world!'; + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].post.requestBody).to.eql( + testRequestSchema, + ); + }); + + it('Allows for a response schema using the spec', () => { + class MyController { + @get('/greet', { + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: { + 'x-ts-type': SuccessModel, + }, + }, + }, + }, + }, + }) + greet() { + return new SuccessModel({message: 'hello, world'}); + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[200]).to.eql(successSchema); + expect(actualSpec.components?.schemas?.SuccessModel).to.not.be.undefined(); + }); + + it('Allows `anyOf` responses', () => { + class MyController { + @get('/greet', { + responses: { + 404: { + description: 'Failure', + content: { + 'application/json': { + schema: { + anyOf: [{'x-ts-type': FooError}, {'x-ts-type': BarError}], + }, + }, + }, + }, + }, + }) + greet() { + throw new FooError({foo: 'foo'}); + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[404]).to.eql( + fooBarSchema('anyOf'), + ); + }); + it('Allows `allOf` responses', () => { + class MyController { + @get('/greet', { + responses: { + 404: { + description: 'Failure', + content: { + 'application/json': { + schema: { + allOf: [{'x-ts-type': FooError}, {'x-ts-type': BarError}], + }, + }, + }, + }, + }, + }) + greet() { + throw new FooError({foo: 'foo'}); + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[404]).to.eql( + fooBarSchema('allOf'), + ); + }); + + it('Allows `oneOf` responses', () => { + class MyController { + @get('/greet', { + responses: { + 404: { + description: 'Failure', + content: { + 'application/json': { + schema: { + oneOf: [{'x-ts-type': FooError}, {'x-ts-type': BarError}], + }, + }, + }, + }, + }, + }) + greet() { + throw new FooError({foo: 'foo'}); + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[404]).to.eql( + fooBarSchema('oneOf'), + ); + }); + it('Allows `not` responses', () => { + class MyController { + @get('/greet', { + responses: { + 404: { + description: 'Failure', + content: { + 'application/json': { + schema: { + not: {'x-ts-type': BarError}, + }, + }, + }, + }, + }, + }) + greet() { + throw new FooError({foo: 'foo'}); + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[404]).to.eql(notSchema); + }); +}); diff --git a/packages/openapi-v3/src/build-responses-from-metadata.ts b/packages/openapi-v3/src/build-responses-from-metadata.ts new file mode 100644 index 000000000000..30a532d36e36 --- /dev/null +++ b/packages/openapi-v3/src/build-responses-from-metadata.ts @@ -0,0 +1,150 @@ +import {Model} from '@loopback/repository'; +import _ from 'lodash'; +import { + ContentObject, + OperationObject, + ResponseDecoratorMetadata, + ResponseModelOrSpec, + ResponseObject, +} from './types'; + +declare type ContentMap = Map; +declare type ResponseMap = Map< + number, + {description: string; content: ContentMap} +>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isModel(c: any): c is T { + return c && c.prototype instanceof Model; +} + +/** + * Reducer which builds the operation responses + */ +function reduceSpecContent( + specContents: ContentObject, + [contentType, modelOrSpecs]: [string, ResponseModelOrSpec[]], +): ContentObject { + if (Array.isArray(modelOrSpecs) && modelOrSpecs.length > 1) { + specContents[contentType] = { + schema: { + anyOf: modelOrSpecs.map(m => { + if (isModel(m)) { + return {'x-ts-type': m}; + } else { + return m; + } + }), + }, + }; + } else { + const modelOrSpec = Array.isArray(modelOrSpecs) + ? modelOrSpecs[0] + : modelOrSpecs; + if (isModel(modelOrSpec)) { + specContents[contentType] = { + schema: {'x-ts-type': modelOrSpec}, + }; + } else { + specContents[contentType] = { + schema: modelOrSpec, + }; + } + } + return specContents; +} + +/** + * Reducer which builds the content sections of the operation responses + */ +function reduceSpecResponses( + specResponses: ResponseObject, + [responseCode, c]: [number, {description: string; content: ContentMap}], +): ResponseObject { + const responseContent = c.content; + // check if there is an existing block, from something like an inhered @op spec + if (Object.prototype.hasOwnProperty.call(specResponses, responseCode)) { + // we might need to merge + const content = Array.from(responseContent).reduce( + reduceSpecContent, + specResponses[responseCode].content as ContentObject, + ); + + specResponses[responseCode] = { + description: c.description, + content, + }; + } else { + const content = Array.from(responseContent).reduce( + reduceSpecContent, + {} as ContentObject, + ); + + specResponses[responseCode] = { + description: c.description, + content, + }; + } + return specResponses; +} + +/** + * This function takes an array of flat-ish data: + * ``` + * [ + * { responseCode, contentType, description, modelOrSpec }, + * { responseCode, contentType, description, modelOrSpec }, + * ] + * ``` + * and turns it into a multi-map structure that more closely aligns with + * the final json + * ``` + * Map{ [code, Map{[contentType, modelOrSpec], [contentType, modelOrSpec]}]} + * ``` + */ +function buildMapsFromMetadata( + metadata: ResponseDecoratorMetadata, +): ResponseMap { + const builder: ResponseMap = new Map(); + metadata.forEach(r => { + if (builder.has(r.responseCode)) { + const responseRef = builder.get(r.responseCode); + const codeRef = responseRef?.content; + + if (codeRef?.has(r.contentType)) { + // eslint-disable-next-line no-unused-expressions + codeRef.get(r.contentType)?.push(r.responseModelOrSpec); + } else { + // eslint-disable-next-line no-unused-expressions + codeRef?.set(r.contentType, [r.responseModelOrSpec]); + } + } else { + const codeRef = new Map(); + codeRef.set(r.contentType, [r.responseModelOrSpec]); + builder.set(r.responseCode, { + description: r.description, + content: codeRef, + }); + } + }); + return builder; +} +export function buildResponsesFromMetadata( + metadata: ResponseDecoratorMetadata, + existingOperation?: OperationObject, +): OperationObject { + const builder = buildMapsFromMetadata(metadata); + const base = existingOperation + ? _.cloneDeep(existingOperation.responses) + : {}; + // Now, mega-reduce. + const responses: ResponseObject = Array.from(builder).reduce( + reduceSpecResponses, + base as ResponseObject, + ); + + return { + responses, + }; +} diff --git a/packages/openapi-v3/src/controller-spec.ts b/packages/openapi-v3/src/controller-spec.ts index 41249c151291..9ee30712f154 100644 --- a/packages/openapi-v3/src/controller-spec.ts +++ b/packages/openapi-v3/src/controller-spec.ts @@ -10,6 +10,7 @@ import { JsonSchemaOptions, } from '@loopback/repository-json-schema'; import {includes} from 'lodash'; +import {buildResponsesFromMetadata} from './build-responses-from-metadata'; import {resolveSchema} from './generate-schema'; import {jsonToSchemaObject, SchemaRef} from './json-to-schema'; import {OAI3Keys} from './keys'; @@ -22,9 +23,11 @@ import { PathObject, ReferenceObject, RequestBodyObject, + ResponseDecoratorMetadata, ResponseObject, SchemaObject, SchemasObject, + TagsDecoratorMetadata, } from './types'; const debug = require('debug')('loopback:openapi3:metadata:controller-spec'); @@ -77,6 +80,48 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { spec = {paths: {}}; } + const isClassDeprecated = MetadataInspector.getClassMetadata( + OAI3Keys.DEPRECATED_CLASS_KEY, + constructor, + ); + + const classTags = MetadataInspector.getClassMetadata( + OAI3Keys.TAGS_CLASS_KEY, + constructor, + ); + + if (isClassDeprecated) { + debug(' using class-level @deprecated()'); + } + + if (classTags) { + debug(' using class-level @tags()'); + } + + if (isClassDeprecated || classTags) { + for (const path of Object.keys(spec.paths)) { + for (const method of Object.keys(spec.paths[path])) { + if (isClassDeprecated) { + spec.paths[path][method].deprecated = true; + } + + if (classTags) { + if ( + classTags.append && + spec.paths[path][method].tags && + spec.paths[path][method].tags.length + ) { + spec.paths[path][method].tags = spec.paths[path][ + method + ].tags.concat(classTags.tags); + } else { + spec.paths[path][method].tags = classTags.tags; + } + } + } + } + } + let endpoints = MetadataInspector.getAllMethodMetadata( OAI3Keys.METHODS_KEY, @@ -91,6 +136,24 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { const verb = endpoint.verb!; const path = endpoint.path!; + const isMethodDeprecated = MetadataInspector.getMethodMetadata( + OAI3Keys.DEPRECATED_METHOD_KEY, + constructor.prototype, + op, + ); + + const methodTags = MetadataInspector.getMethodMetadata< + TagsDecoratorMetadata + >(OAI3Keys.TAGS_METHOD_KEY, constructor.prototype, op); + + if (isMethodDeprecated) { + debug(' using method-level deprecation via @deprecated()'); + } + + if (methodTags) { + debug(' using method-level tags via @tags()'); + } + let endpointName = ''; /* istanbul ignore if */ if (debug.enabled) { @@ -106,13 +169,55 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { }; let operationSpec = endpoint.spec; + + const decoratedResponses = MetadataInspector.getMethodMetadata< + ResponseDecoratorMetadata + >(OAI3Keys.RESPONSE_METHOD_KEY, constructor.prototype, op); + if (!operationSpec) { - // The operation was defined via @operation(verb, path) with no spec - operationSpec = { - responses: defaultResponse, - }; + if (decoratedResponses) { + operationSpec = buildResponsesFromMetadata(decoratedResponses); + } else { + // The operation was defined via @operation(verb, path) with no spec + operationSpec = { + responses: defaultResponse, + }; + } endpoint.spec = operationSpec; + } else if (decoratedResponses) { + operationSpec = buildResponsesFromMetadata( + decoratedResponses, + operationSpec, + ); + } + + // Prescedence: method decorator > class decorator > operationSpec > undefined + const deprecationSpec = + isMethodDeprecated ?? + isClassDeprecated ?? + operationSpec.deprecated ?? + false; + + if (deprecationSpec) { + operationSpec.deprecated = true; } + + if (classTags && !operationSpec.tags) { + operationSpec.tags = classTags.tags; + } + + if (methodTags) { + if ( + methodTags.append && + operationSpec.tags && + operationSpec.tags.length + ) { + operationSpec.tags = operationSpec.tags.concat(methodTags.tags); + } else { + operationSpec.tags = methodTags.tags; + } + } + debug(' operation for method %s: %j', op, endpoint); debug(' spec responses for method %s: %o', op, operationSpec.responses); @@ -233,6 +338,8 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { return spec; } +declare type MixKey = 'allOf' | 'anyOf' | 'oneOf'; +const SCHEMA_ARR_KEYS: MixKey[] = ['allOf', 'anyOf', 'oneOf']; /** * Resolve the x-ts-type in the schema object * @param spec - Controller spec @@ -248,24 +355,50 @@ function processSchemaExtensions( assignRelatedSchemas(spec, schema.definitions); delete schema.definitions; - if (isReferenceObject(schema)) return; + /** + * check if we have been provided a `not` + * `not` is valid in many cases- here we're checking for + * `not: { schema: {'x-ts-type': SomeModel }} + */ + if (schema.not) { + processSchemaExtensions(spec, schema.not); + } - const tsType = schema[TS_TYPE_KEY]; - debug(' %s => %o', TS_TYPE_KEY, tsType); - if (tsType) { - schema = resolveSchema(tsType, schema); - if (schema.$ref) generateOpenAPISchema(spec, tsType); + /** + * check for schema.allOf, schema.oneOf, schema.anyOf arrays first. + * You cannot provide BOTH a defnintion AND one of these keywords. + */ + const hasOwn = (prop: string) => + Object.prototype.hasOwnProperty.call(schema, prop); + + if (SCHEMA_ARR_KEYS.some(k => hasOwn(k))) { + SCHEMA_ARR_KEYS.forEach((k: MixKey) => { + if (schema?.[k] && Array.isArray(schema[k])) { + schema[k].forEach((r: (SchemaObject | ReferenceObject)[]) => { + processSchemaExtensions(spec, r); + }); + } + }); + } else { + if (isReferenceObject(schema)) return; - // We don't want a Function type in the final spec. - delete schema[TS_TYPE_KEY]; - return; - } - if (schema.type === 'array') { - processSchemaExtensions(spec, schema.items); - } else if (schema.type === 'object') { - if (schema.properties) { - for (const p in schema.properties) { - processSchemaExtensions(spec, schema.properties[p]); + const tsType = schema[TS_TYPE_KEY]; + debug(' %s => %o', TS_TYPE_KEY, tsType); + if (tsType) { + schema = resolveSchema(tsType, schema); + if (schema.$ref) generateOpenAPISchema(spec, tsType); + + // We don't want a Function type in the final spec. + delete schema[TS_TYPE_KEY]; + return; + } + if (schema.type === 'array') { + processSchemaExtensions(spec, schema.items); + } else if (schema.type === 'object') { + if (schema.properties) { + for (const p in schema.properties) { + processSchemaExtensions(spec, schema.properties[p]); + } } } } diff --git a/packages/openapi-v3/src/decorators/deprecated.decorator.ts b/packages/openapi-v3/src/decorators/deprecated.decorator.ts new file mode 100644 index 000000000000..b790498ad9c9 --- /dev/null +++ b/packages/openapi-v3/src/decorators/deprecated.decorator.ts @@ -0,0 +1,51 @@ +// 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 { + ClassDecoratorFactory, + DecoratorFactory, + MethodDecoratorFactory, +} from '@loopback/core'; +import {OAI3Keys} from '../keys'; + +const debug = require('debug')( + 'loopback:openapi3:metadata:controller-spec:deprecated', +); + +export function deprecated(isDeprecated = true) { + return function deprecatedDecoratorForClassOrMethod( + // Class or a prototype + // eslint-disable-next-line @typescript-eslint/no-explicit-any + target: any, + method?: string, + // Use `any` to for `TypedPropertyDescriptor` + // See https://github.com/strongloop/loopback-next/pull/2704 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + methodDescriptor?: TypedPropertyDescriptor, + ) { + debug(target, method, methodDescriptor); + + if (method && methodDescriptor) { + // Method + return MethodDecoratorFactory.createDecorator( + OAI3Keys.DEPRECATED_METHOD_KEY, + isDeprecated, + {decoratorName: '@deprecated'}, + )(target, method, methodDescriptor); + } else if (typeof target === 'function' && !method && !methodDescriptor) { + // Class + return ClassDecoratorFactory.createDecorator( + OAI3Keys.DEPRECATED_CLASS_KEY, + isDeprecated, + {decoratorName: '@deprecated'}, + )(target); + } else { + throw new Error( + '@deprecated cannot be used on a property: ' + + DecoratorFactory.getTargetName(target, method, methodDescriptor), + ); + } + }; +} diff --git a/packages/openapi-v3/src/decorators/index.ts b/packages/openapi-v3/src/decorators/index.ts index 947da4a6f0e4..20003c5db028 100644 --- a/packages/openapi-v3/src/decorators/index.ts +++ b/packages/openapi-v3/src/decorators/index.ts @@ -4,6 +4,9 @@ // License text available at https://opensource.org/licenses/MIT export * from './api.decorator'; +export * from './deprecated.decorator'; export * from './operation.decorator'; export * from './parameter.decorator'; export * from './request-body.decorator'; +export * from './response.decorator'; +export * from './tags.decorator'; diff --git a/packages/openapi-v3/src/decorators/response.decorator.ts b/packages/openapi-v3/src/decorators/response.decorator.ts new file mode 100644 index 000000000000..c5d906253e23 --- /dev/null +++ b/packages/openapi-v3/src/decorators/response.decorator.ts @@ -0,0 +1,48 @@ +// Copyright IBM Corp. 2018,2019. 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 {MethodMultiDecoratorFactory} from '@loopback/core'; +import * as httpStatus from 'http-status'; +import {OAI3Keys} from '../keys'; +import {ResponseModelOrSpec} from '../types'; + +export interface ResponseOptions { + contentType?: string; + description?: string; +} +export function response( + responseCode: number, + responseModelOrSpec: ResponseModelOrSpec, + options?: ResponseOptions, +) { + const coercedCode = String(responseCode) as keyof httpStatus.HttpStatus; + const messageKey = `${coercedCode}_MESSAGE` as keyof httpStatus.HttpStatus; + + if (Array.isArray(responseModelOrSpec)) { + return MethodMultiDecoratorFactory.createDecorator( + OAI3Keys.RESPONSE_METHOD_KEY, + responseModelOrSpec.map(m => ({ + responseCode, + responseModelOrSpec: m, + contentType: options?.contentType ?? 'application/json', + description: options?.description ?? (httpStatus[messageKey] as string), + })), + {decoratorName: '@response', allowInheritance: false}, + ); + } else { + return MethodMultiDecoratorFactory.createDecorator( + OAI3Keys.RESPONSE_METHOD_KEY, + [ + { + responseCode, + responseModelOrSpec, + contentType: options?.contentType ?? 'application/json', + description: + options?.description ?? (httpStatus[messageKey] as string), + }, + ], + {decoratorName: '@response', allowInheritance: false}, + ); + } +} diff --git a/packages/openapi-v3/src/decorators/tags.decorator.ts b/packages/openapi-v3/src/decorators/tags.decorator.ts new file mode 100644 index 000000000000..0bf02397f011 --- /dev/null +++ b/packages/openapi-v3/src/decorators/tags.decorator.ts @@ -0,0 +1,50 @@ +// 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 { + ClassDecoratorFactory, + DecoratorFactory, + MethodDecoratorFactory, +} from '@loopback/core'; +import {OAI3Keys} from '../keys'; +import {TagsDecoratorMetadata} from '../types'; + +export interface TagDecoratorOptions { + append: boolean; +} + +export function tags(tagNames: string[], options?: TagDecoratorOptions) { + return function tagsDecoratorForClassOrMethod( + // Class or a prototype + // eslint-disable-next-line @typescript-eslint/no-explicit-any + target: any, + method?: string, + // Use `any` to for `TypedPropertyDescriptor` + // See https://github.com/strongloop/loopback-next/pull/2704 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + methodDescriptor?: TypedPropertyDescriptor, + ) { + if (method && methodDescriptor) { + // Method + return MethodDecoratorFactory.createDecorator( + OAI3Keys.TAGS_METHOD_KEY, + {tags: tagNames, append: options?.append ?? false}, + {decoratorName: '@tags'}, + )(target, method, methodDescriptor); + } else if (typeof target === 'function' && !method && !methodDescriptor) { + // Class + return ClassDecoratorFactory.createDecorator( + OAI3Keys.TAGS_CLASS_KEY, + {tags: tagNames, append: options?.append ?? false}, + {decoratorName: '@tags'}, + )(target); + } else { + throw new Error( + '@tags cannot be used on a property: ' + + DecoratorFactory.getTargetName(target, method, methodDescriptor), + ); + } + }; +} diff --git a/packages/openapi-v3/src/keys.ts b/packages/openapi-v3/src/keys.ts index a6d6cb7771ff..fe1e32d73951 100644 --- a/packages/openapi-v3/src/keys.ts +++ b/packages/openapi-v3/src/keys.ts @@ -5,7 +5,11 @@ import {MetadataAccessor} from '@loopback/core'; import {ControllerSpec, RestEndpoint} from './controller-spec'; -import {ParameterObject, RequestBodyObject} from './types'; +import { + ParameterObject, + RequestBodyObject, + ResponseDecoratorMetadata, +} from './types'; export namespace OAI3Keys { /** @@ -16,6 +20,46 @@ export namespace OAI3Keys { MethodDecorator >('openapi-v3:methods'); + /** + * Metadata key used to set or retrieve `@deprecated` metadata on a method. + */ + export const DEPRECATED_METHOD_KEY = MetadataAccessor.create< + boolean, + MethodDecorator + >('openapi-v3:methods:deprecated'); + + /** + * Metadata key used to set or retrieve `@deprecated` metadata on a class + */ + export const DEPRECATED_CLASS_KEY = MetadataAccessor.create< + boolean, + ClassDecorator + >('openapi-v3:class:deprecated'); + + /** + * Metadata key used to set or retrieve `@deprecated` metadata on a method. + */ + export const TAGS_METHOD_KEY = MetadataAccessor.create< + string[], + MethodDecorator + >('openapi-v3:methods:tags'); + + /** + * Metadata key used to set or retrieve `@deprecated` metadata on a class + */ + export const TAGS_CLASS_KEY = MetadataAccessor.create< + string[], + ClassDecorator + >('openapi-v3:class:tags'); + + /** + * Metadata key used to add to or retrieve an endpoint's responses + */ + export const RESPONSE_METHOD_KEY = MetadataAccessor.create< + ResponseDecoratorMetadata, + MethodDecorator + >('openapi-v3:methods:response'); + /** * Metadata key used to set or retrieve `param` decorator metadata */ diff --git a/packages/openapi-v3/src/types.ts b/packages/openapi-v3/src/types.ts index c420f4fbc7ec..0ce1b204c31c 100644 --- a/packages/openapi-v3/src/types.ts +++ b/packages/openapi-v3/src/types.ts @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {OpenAPIObject} from 'openapi3-ts'; +import {OpenAPIObject, ReferenceObject, SchemaObject} from 'openapi3-ts'; /* * OpenApiSpec - A typescript representation of OpenApi 3.0.0 */ @@ -28,3 +28,23 @@ export function createEmptyApiSpec(): OpenApiSpec { servers: [{url: '/'}], }; } + +export interface TagsDecoratorMetadata { + tags: string[]; + append: boolean; +} + +export declare type ResponseModelOrSpec = + | Function + | Function[] + | SchemaObject + | ReferenceObject; + +export interface ResponseDecoratorMetadataItem { + responseCode: number; + contentType: string; + responseModelOrSpec: ResponseModelOrSpec; + description: string; +} + +export declare type ResponseDecoratorMetadata = ResponseDecoratorMetadataItem[]; diff --git a/sandbox/example/package-lock.json b/sandbox/example/package-lock.json new file mode 100644 index 000000000000..8ecf65e4eae4 --- /dev/null +++ b/sandbox/example/package-lock.json @@ -0,0 +1,14 @@ +{ + "name": "@loopback/sandbox-example", + "version": "1.0.9", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "http-status": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/http-status/-/http-status-1.4.2.tgz", + "integrity": "sha512-mBnIohUwRw9NyXMEMMv8/GANnzEYUj0Y8d3uL01zDWFkxUjYyZ6rgCaAI2zZ1Wb34Oqtbx/nFZolPRDc8Xlm5A==", + "dev": true + } + } +}