From e20fa2c7868a5625695d47a1b76332c65e4735ab Mon Sep 17 00:00:00 2001 From: Douglas McConnachie Date: Mon, 3 Feb 2020 14:51:12 +0000 Subject: [PATCH 1/2] refactor(rest): make getApiSpec() async BREAKING CHANGE: Api specifications are now emitted as a Promise instead of a value object. Calls to getApiSpec function must switch from the old style to new style as follows: 1. Old style ```ts function() { // ... const spec = restApp.restServer.getApiSpec(); // ... } ``` 2. New style ```ts async function() { // ... const spec = await restApp.restServer.getApiSpec(); // ... } ``` --- .../__tests__/acceptance/lb3app.acceptance.ts | 2 +- .../acceptance/booter-lb3app.acceptance.ts | 16 +++---- .../default-model-crud-rest.acceptance.ts | 8 ++-- .../rest.application.integration.ts | 4 +- .../rest.server.open-api-spec.unit.ts | 42 +++++++++---------- packages/rest/src/rest.server.ts | 6 +-- 6 files changed, 39 insertions(+), 39 deletions(-) diff --git a/examples/lb3-application/src/__tests__/acceptance/lb3app.acceptance.ts b/examples/lb3-application/src/__tests__/acceptance/lb3app.acceptance.ts index 447d7353326f..784629bfad08 100644 --- a/examples/lb3-application/src/__tests__/acceptance/lb3app.acceptance.ts +++ b/examples/lb3-application/src/__tests__/acceptance/lb3app.acceptance.ts @@ -138,7 +138,7 @@ describe('CoffeeShopApplication', () => { let apiSpec: OpenApiSpec; before(async () => { - apiSpec = app.lbApp.restServer.getApiSpec(); + apiSpec = await app.lbApp.restServer.getApiSpec(); }); it('has the same properties in both the LB3 and LB4 specs', () => { diff --git a/packages/booter-lb3app/src/__tests__/acceptance/booter-lb3app.acceptance.ts b/packages/booter-lb3app/src/__tests__/acceptance/booter-lb3app.acceptance.ts index d632a1141f4f..7ad9bf303e6d 100644 --- a/packages/booter-lb3app/src/__tests__/acceptance/booter-lb3app.acceptance.ts +++ b/packages/booter-lb3app/src/__tests__/acceptance/booter-lb3app.acceptance.ts @@ -41,8 +41,8 @@ describe('booter-lb3app', () => { }); context('generated OpenAPI spec', () => { - it('uses different request-body schema for "create" operation', () => { - const spec = app.restServer.getApiSpec(); + it('uses different request-body schema for "create" operation', async () => { + const spec = await app.restServer.getApiSpec(); const createOp: OperationObject = spec.paths['/api/CoffeeShops'].post; expect(createOp.requestBody).to.containDeep({ content: { @@ -66,8 +66,8 @@ describe('booter-lb3app', () => { }); }); - it('includes the target model as a property of the source model in a relation', () => { - const spec = app.restServer.getApiSpec(); + it('includes the target model as a property of the source model in a relation', async () => { + const spec = await app.restServer.getApiSpec(); const schemas = (spec.components ?? {}).schemas ?? {}; expect(schemas.CoffeeShop) @@ -118,8 +118,8 @@ describe('booter-lb3app', () => { } }); - it('includes LoopBack 3 endpoints with `/api` base in OpenApiSpec', () => { - const apiSpec = app.restServer.getApiSpec(); + it('includes LoopBack 3 endpoints with `/api` base in OpenApiSpec', async () => { + const apiSpec = await app.restServer.getApiSpec(); const paths = Object.keys(apiSpec.paths); expect(paths).to.containDeep([ '/api/CoffeeShops/{id}', @@ -219,8 +219,8 @@ describe('booter-lb3app', () => { })); }); - it('does apply the spec modification', () => { - const spec = app.restServer.getApiSpec(); + it('does apply the spec modification', async () => { + const spec = await app.restServer.getApiSpec(); const createOp: OperationObject = spec.paths['/api/CoffeeShops'].post; expect(createOp.summary).to.eql('just a very simple modification'); }); diff --git a/packages/rest-crud/src/__tests__/acceptance/default-model-crud-rest.acceptance.ts b/packages/rest-crud/src/__tests__/acceptance/default-model-crud-rest.acceptance.ts index 6e6caa438f63..c10b4f92bbbb 100644 --- a/packages/rest-crud/src/__tests__/acceptance/default-model-crud-rest.acceptance.ts +++ b/packages/rest-crud/src/__tests__/acceptance/default-model-crud-rest.acceptance.ts @@ -126,7 +126,7 @@ describe('CrudRestController for a simple Product model', () => { // a new test suite that will configure a PK with a different name // and type, e.g. `pk: string` instead of `id: number`. it('uses correct schema for the id parameter', async () => { - const spec = app.restServer.getApiSpec(); + const spec = await app.restServer.getApiSpec(); const findByIdOp = spec.paths['/products/{id}'].get; expect(findByIdOp).to.containDeep({ parameters: [ @@ -210,7 +210,7 @@ describe('CrudRestController for a simple Product model', () => { // a new test suite that will configure a PK with a different name // and type, e.g. `pk: string` instead of `id: number`. it('uses correct schema for the id parameter', async () => { - const spec = app.restServer.getApiSpec(); + const spec = await app.restServer.getApiSpec(); const findByIdOp = spec.paths['/products/{id}'].patch; expect(findByIdOp).to.containDeep({ parameters: [ @@ -245,7 +245,7 @@ describe('CrudRestController for a simple Product model', () => { // a new test suite that will configure a PK with a different name // and type, e.g. `pk: string` instead of `id: number`. it('uses correct schema for the id parameter', async () => { - const spec = app.restServer.getApiSpec(); + const spec = await app.restServer.getApiSpec(); const findByIdOp = spec.paths['/products/{id}']['patch']; expect(findByIdOp).to.containDeep({ parameters: [ @@ -278,7 +278,7 @@ describe('CrudRestController for a simple Product model', () => { // a new test suite that will configure a PK with a different name // and type, e.g. `pk: string` instead of `id: number`. it('uses correct schema for the id parameter', async () => { - const spec = app.restServer.getApiSpec(); + const spec = await app.restServer.getApiSpec(); const findByIdOp = spec.paths['/products/{id}']['delete']; expect(findByIdOp).to.containDeep({ parameters: [ diff --git a/packages/rest/src/__tests__/integration/rest.application.integration.ts b/packages/rest/src/__tests__/integration/rest.application.integration.ts index 79d67b9a22d7..808f50afe958 100644 --- a/packages/rest/src/__tests__/integration/rest.application.integration.ts +++ b/packages/rest/src/__tests__/integration/rest.application.integration.ts @@ -139,7 +139,7 @@ describe('RestApplication (integration)', () => { 'x-foo': 'bar', }); - const spec = restApp.restServer.getApiSpec(); + const spec = await restApp.restServer.getApiSpec(); expect(spec).to.deepEqual({ openapi: '3.0.0', info: { @@ -231,7 +231,7 @@ describe('RestApplication (integration)', () => { restApp.mountExpressRouter('/dogs', router, spec); await client.get('/dogs/hello').expect(200, 'Hello dogs!'); - const openApiSpec = restApp.restServer.getApiSpec(); + const openApiSpec = await restApp.restServer.getApiSpec(); expect(openApiSpec.paths).to.deepEqual({ '/dogs/hello': { get: { diff --git a/packages/rest/src/__tests__/unit/rest.server/rest.server.open-api-spec.unit.ts b/packages/rest/src/__tests__/unit/rest.server/rest.server.open-api-spec.unit.ts index c4f1fc2a509e..97f81efdab15 100644 --- a/packages/rest/src/__tests__/unit/rest.server/rest.server.open-api-spec.unit.ts +++ b/packages/rest/src/__tests__/unit/rest.server/rest.server.open-api-spec.unit.ts @@ -21,10 +21,10 @@ describe('RestServer.getApiSpec()', () => { beforeEach(givenApplication); it('comes with a valid default spec', async () => { - await validateApiSpec(server.getApiSpec()); + await validateApiSpec(await server.getApiSpec()); }); - it('honours API defined via app.api()', () => { + it('honours API defined via app.api()', async () => { server.api({ openapi: '3.0.0', info: { @@ -36,7 +36,7 @@ describe('RestServer.getApiSpec()', () => { 'x-foo': 'bar', }); - const spec = server.getApiSpec(); + const spec = await server.getApiSpec(); expect(spec).to.deepEqual({ openapi: '3.0.0', info: { @@ -81,11 +81,11 @@ describe('RestServer.getApiSpec()', () => { }); }); - it('returns routes registered via app.route(route)', () => { + it('returns routes registered via app.route(route)', async () => { function greet() {} server.route('get', '/greet', {responses: {}}, greet); - const spec = server.getApiSpec(); + const spec = await server.getApiSpec(); expect(spec.paths).to.eql({ '/greet': { get: { @@ -95,7 +95,7 @@ describe('RestServer.getApiSpec()', () => { }); }); - it('ignores routes marked as "x-visibility" via app.route(route)', () => { + it('ignores routes marked as "x-visibility" via app.route(route)', async () => { function greet() {} function meet() {} server.route( @@ -105,7 +105,7 @@ describe('RestServer.getApiSpec()', () => { greet, ); server.route('get', '/meet', {responses: {}, spec: {}}, meet); - const spec = server.getApiSpec(); + const spec = await server.getApiSpec(); expect(spec.paths).to.eql({ '/meet': { get: { @@ -116,7 +116,7 @@ describe('RestServer.getApiSpec()', () => { }); }); - it('returns routes registered via app.route(..., Controller, method)', () => { + it('returns routes registered via app.route(..., Controller, method)', async () => { class MyController { greet() {} } @@ -130,7 +130,7 @@ describe('RestServer.getApiSpec()', () => { 'greet', ); - const spec = server.getApiSpec(); + const spec = await server.getApiSpec(); expect(spec.paths).to.eql({ '/greet': { get: { @@ -143,7 +143,7 @@ describe('RestServer.getApiSpec()', () => { }); }); - it('ignores routes marked as "x-visibility" via app.route(..., Controller, method)', () => { + it('ignores routes marked as "x-visibility" via app.route(..., Controller, method)', async () => { class GreetController { greet() {} } @@ -170,7 +170,7 @@ describe('RestServer.getApiSpec()', () => { 'meet', ); - const spec = server.getApiSpec(); + const spec = await server.getApiSpec(); expect(spec.paths).to.eql({ '/meet': { get: { @@ -183,14 +183,14 @@ describe('RestServer.getApiSpec()', () => { }); }); - it('honors tags in the operation spec', () => { + it('honors tags in the operation spec', async () => { class MyController { @get('/greet', {responses: {'200': {description: ''}}, tags: ['MyTag']}) greet() {} } app.controller(MyController); - const spec = server.getApiSpec(); + const spec = await server.getApiSpec(); expect(spec.paths).to.eql({ '/greet': { get: { @@ -204,7 +204,7 @@ describe('RestServer.getApiSpec()', () => { }); }); - it('emits all media types for request body', () => { + it('emits all media types for request body', async () => { const expectedOpSpec = anOperationSpec() .withRequestBody({ description: 'Any object value.', @@ -238,18 +238,18 @@ describe('RestServer.getApiSpec()', () => { } app.controller(MyController); - const spec = server.getApiSpec(); + const spec = await server.getApiSpec(); expect(spec.paths['/show-body'].post).to.containDeep(expectedOpSpec); }); - it('returns routes registered via app.controller()', () => { + it('returns routes registered via app.controller()', async () => { class MyController { @get('/greet') greet() {} } app.controller(MyController); - const spec = server.getApiSpec(); + const spec = await server.getApiSpec(); expect(spec.paths).to.eql({ '/greet': { get: { @@ -265,7 +265,7 @@ describe('RestServer.getApiSpec()', () => { }); }); - it('returns definitions inferred via app.controller()', () => { + it('returns definitions inferred via app.controller()', async () => { @model() class MyModel { @property() @@ -277,7 +277,7 @@ describe('RestServer.getApiSpec()', () => { } app.controller(MyController); - const spec = server.getApiSpec(); + const spec = await server.getApiSpec(); expect(spec.components && spec.components.schemas).to.deepEqual({ MyModel: { title: 'MyModel', @@ -291,7 +291,7 @@ describe('RestServer.getApiSpec()', () => { }); }); - it('preserves routes specified in app.api()', () => { + it('preserves routes specified in app.api()', async () => { function status() {} server.api( anOpenApiSpec() @@ -305,7 +305,7 @@ describe('RestServer.getApiSpec()', () => { function greet() {} server.route('get', '/greet', {responses: {}}, greet); - const spec = server.getApiSpec(); + const spec = await server.getApiSpec(); expect(spec.paths).to.eql({ '/greet': { get: { diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 01417ddd051b..fcd21a2b8f94 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -497,7 +497,7 @@ export class RestServer extends Context implements Server, HttpServerLike { ); specForm = specForm ?? {version: '3.0.0', format: 'json'}; - const specObj = this.getApiSpec(requestContext); + const specObj = await this.getApiSpec(requestContext); if (specForm.format === 'json') { const spec = JSON.stringify(specObj, null, 2); @@ -777,8 +777,8 @@ export class RestServer extends Context implements Server, HttpServerLike { * @param requestContext - Optional context to update the `servers` list * in the returned spec */ - getApiSpec(requestContext?: RequestContext): OpenApiSpec { - let spec = this.getSync(RestBindings.API_SPEC); + async getApiSpec(requestContext?: RequestContext): Promise { + let spec = await this.get(RestBindings.API_SPEC); const defs = this.httpHandler.getApiDefinitions(); // Apply deep clone to prevent getApiSpec() callers from From 7558cee5aea0b749762869f1e2b3e8641a6c481d Mon Sep 17 00:00:00 2001 From: Douglas McConnachie Date: Mon, 24 Feb 2020 19:48:46 +0000 Subject: [PATCH 2/2] feat(rest): add openapi enhancer service add openapi spec enhancer to rest server impl. #4380 Signed-off-by: Douglas McConnachie --- .../fixtures/info.spec.extension.ts | 33 +++++++++++++++++++ .../rest.server.open-api-spec.unit.ts | 13 +++++++- packages/rest/src/rest.server.ts | 23 +++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 packages/rest/src/__tests__/unit/rest.server/fixtures/info.spec.extension.ts diff --git a/packages/rest/src/__tests__/unit/rest.server/fixtures/info.spec.extension.ts b/packages/rest/src/__tests__/unit/rest.server/fixtures/info.spec.extension.ts new file mode 100644 index 000000000000..7c10ba3581fc --- /dev/null +++ b/packages/rest/src/__tests__/unit/rest.server/fixtures/info.spec.extension.ts @@ -0,0 +1,33 @@ +// 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 {bind} from '@loopback/core'; +import debugModule from 'debug'; +import {inspect} from 'util'; +import { + asSpecEnhancer, + mergeOpenAPISpec, + OASEnhancer, + OpenApiSpec, +} from '../../../..'; + +const debug = debugModule('loopback:openapi:spec-enhancer'); + +/** + * A spec enhancer to add OpenAPI info spec + */ +@bind(asSpecEnhancer) +export class InfoSpecEnhancer implements OASEnhancer { + name = 'info'; + + modifySpec(spec: OpenApiSpec): OpenApiSpec { + const InfoPatchSpec = { + info: {title: 'LoopBack Test Application', version: '1.0.1'}, + }; + const mergedSpec = mergeOpenAPISpec(spec, InfoPatchSpec); + debug(`security spec extension, merged spec: ${inspect(mergedSpec)}`); + return mergedSpec; + } +} diff --git a/packages/rest/src/__tests__/unit/rest.server/rest.server.open-api-spec.unit.ts b/packages/rest/src/__tests__/unit/rest.server/rest.server.open-api-spec.unit.ts index 97f81efdab15..0d9ae7a70dd2 100644 --- a/packages/rest/src/__tests__/unit/rest.server/rest.server.open-api-spec.unit.ts +++ b/packages/rest/src/__tests__/unit/rest.server/rest.server.open-api-spec.unit.ts @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Application} from '@loopback/core'; +import {Application, createBindingFromClass} from '@loopback/core'; import {anOpenApiSpec, anOperationSpec} from '@loopback/openapi-spec-builder'; import {get, post, requestBody} from '@loopback/openapi-v3'; import {model, property} from '@loopback/repository'; @@ -14,6 +14,7 @@ import { RestServer, } from '../../..'; import {RestTags} from '../../../keys'; +import {InfoSpecEnhancer} from './fixtures/info.spec.extension'; describe('RestServer.getApiSpec()', () => { let app: Application; @@ -320,6 +321,16 @@ describe('RestServer.getApiSpec()', () => { }); }); + it('invokes registered oas enhancers', async () => { + const EXPECTED_SPEC_INFO = { + title: 'LoopBack Test Application', + version: '1.0.1', + }; + server.add(createBindingFromClass(InfoSpecEnhancer)); + const spec = await server.getApiSpec(); + expect(spec.info).to.eql(EXPECTED_SPEC_INFO); + }); + async function givenApplication() { app = new Application(); app.component(RestComponent); diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index fcd21a2b8f94..7a9c393d39fc 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -10,6 +10,7 @@ import { Constructor, Context, ContextObserver, + createBindingFromClass, filterByKey, filterByTag, inject, @@ -19,6 +20,8 @@ import {Application, CoreBindings, Server} from '@loopback/core'; import {HttpServer, HttpServerOptions} from '@loopback/http-server'; import { getControllerSpec, + OASEnhancerService, + OAS_ENHANCER_SERVICE, OpenAPIObject, OpenApiSpec, OperationObject, @@ -134,6 +137,12 @@ export class RestServer extends Context implements Server, HttpServerLike { * @param res - The response. */ + protected _OASEnhancer: OASEnhancerService; + public get OASEnhancer(): OASEnhancerService { + this._setupOASEnhancerIfNeeded(); + return this._OASEnhancer; + } + protected _requestHandler: HttpRequestListener; public get requestHandler(): HttpRequestListener { if (this._requestHandler == null) { @@ -229,6 +238,16 @@ export class RestServer extends Context implements Server, HttpServerLike { this.bind(RestBindings.HANDLER).toDynamicValue(() => this.httpHandler); } + protected _setupOASEnhancerIfNeeded() { + if (this._OASEnhancer != null) return; + this.add( + createBindingFromClass(OASEnhancerService, { + key: OAS_ENHANCER_SERVICE, + }), + ); + this._OASEnhancer = this.getSync(OAS_ENHANCER_SERVICE); + } + protected _setupRequestHandlerIfNeeded() { if (this._expressApp != null) return; this._expressApp = express(); @@ -795,6 +814,10 @@ export class RestServer extends Context implements Server, HttpServerLike { spec = this.updateSpecFromRequest(spec, requestContext); } + // Apply OAS enhancers to the OpenAPI specification + this.OASEnhancer.spec = spec; + spec = await this.OASEnhancer.applyAllEnhancers(); + return spec; }