From 13c4343062ae8e8813ed0840eaf369e15c8bdc37 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Sun, 31 May 2020 16:38:39 -0700 Subject: [PATCH] feat(rest-crud): add model api booter in the CrudRestComponent Signed-off-by: Raymond Feng --- packages/rest-crud/package.json | 4 +- .../crud-rest.api-builder.acceptance.ts | 171 +++++++++++++++++ .../booters/model-api.booter.acceptance.ts | 173 ++++++++++++++++++ .../default-model-crud-rest.acceptance.ts | 6 +- .../src/__tests__/fixtures/application.ts | 16 ++ .../__tests__/fixtures/datasource.artifact.ts | 14 ++ .../src/__tests__/fixtures/no-entity.model.ts | 15 ++ .../src/__tests__/fixtures/package.json | 19 ++ .../src/__tests__/fixtures/product.model.ts | 15 ++ .../__tests__/fixtures/product.repository.ts | 17 ++ .../fixtures/stub-model-api-builder.ts | 67 +++++++ packages/rest-crud/src/crud-rest.component.ts | 10 +- packages/rest-crud/tsconfig.json | 3 + 13 files changed, 525 insertions(+), 5 deletions(-) create mode 100644 packages/rest-crud/src/__tests__/acceptance/booters/crud-rest.api-builder.acceptance.ts create mode 100644 packages/rest-crud/src/__tests__/acceptance/booters/model-api.booter.acceptance.ts create mode 100644 packages/rest-crud/src/__tests__/fixtures/application.ts create mode 100644 packages/rest-crud/src/__tests__/fixtures/datasource.artifact.ts create mode 100644 packages/rest-crud/src/__tests__/fixtures/no-entity.model.ts create mode 100644 packages/rest-crud/src/__tests__/fixtures/package.json create mode 100644 packages/rest-crud/src/__tests__/fixtures/product.model.ts create mode 100644 packages/rest-crud/src/__tests__/fixtures/product.repository.ts create mode 100644 packages/rest-crud/src/__tests__/fixtures/stub-model-api-builder.ts diff --git a/packages/rest-crud/package.json b/packages/rest-crud/package.json index 6176b63d2e28..dce60d7d4992 100644 --- a/packages/rest-crud/package.json +++ b/packages/rest-crud/package.json @@ -26,6 +26,7 @@ "tslib": "^2.0.1" }, "devDependencies": { + "@loopback/boot": "^2.3.2", "@loopback/build": "^1.7.1", "@loopback/core": "^2.9.3", "@loopback/repository": "^2.11.0", @@ -37,7 +38,8 @@ "peerDependencies": { "@loopback/core": "^2.9.3", "@loopback/repository": "^2.11.0", - "@loopback/rest": "^6.0.0" + "@loopback/rest": "^6.0.0", + "@loopback/boot": "^2.3.2" }, "files": [ "README.md", diff --git a/packages/rest-crud/src/__tests__/acceptance/booters/crud-rest.api-builder.acceptance.ts b/packages/rest-crud/src/__tests__/acceptance/booters/crud-rest.api-builder.acceptance.ts new file mode 100644 index 000000000000..b367728c88d3 --- /dev/null +++ b/packages/rest-crud/src/__tests__/acceptance/booters/crud-rest.api-builder.acceptance.ts @@ -0,0 +1,171 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {BootMixin} from '@loopback/boot'; +import {ApplicationConfig} from '@loopback/core'; +import {ModelApiBooter} from '@loopback/model-api-builder'; +import {juggler, RepositoryMixin} from '@loopback/repository'; +import {RestApplication} from '@loopback/rest'; +import {expect, givenHttpServerConfig, TestSandbox} from '@loopback/testlab'; +import {resolve} from 'path'; +import {CrudRestComponent} from '../../..'; +import {ProductRepository} from '../../fixtures/product.repository'; + +describe('CRUD rest builder acceptance tests', () => { + let app: BooterApp; + const sandbox = new TestSandbox(resolve(__dirname, '../../../.sandbox')); + + beforeEach('reset sandbox', () => sandbox.reset()); + beforeEach(givenAppWithDataSource); + + afterEach(stopApp); + + it('binds the controller and repository to the application', async () => { + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/product.model.js'), + 'models/product.model.js', + ); + + // when creating the config file in a real app, make sure to use + // module.exports = {...} + // it's not used here because this is a .js file + await sandbox.writeTextFile( + 'model-endpoints/product.rest-config.js', + ` +const {Product} = require('../models/product.model'); +module.exports = { + model: Product, + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/products', +}; + `, + ); + + // Boot & start the application + await app.boot(); + await app.start(); + + expect(app.getBinding('repositories.ProductRepository').key).to.eql( + 'repositories.ProductRepository', + ); + + expect(app.getBinding('controllers.ProductController').key).to.eql( + 'controllers.ProductController', + ); + }); + + it('uses bound repository class if it exists', async () => { + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/product.model.js'), + 'models/product.model.js', + ); + + await sandbox.writeTextFile( + 'model-endpoints/product.rest-config.js', + ` +const {Product} = require('../models/product.model'); +module.exports = { + model: Product, + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/products', +}; + `, + ); + + app.repository(ProductRepository); + + const bindingName = 'repositories.ProductRepository'; + + const binding = app.getBinding(bindingName); + expect(binding.valueConstructor).to.eql(ProductRepository); + + // Boot & start the application + await app.boot(); + await app.start(); + + // Make sure it is still equal to the defined ProductRepository after + // booting + expect(app.getBinding(bindingName).valueConstructor).to.eql( + ProductRepository, + ); + + expect(app.getBinding('controllers.ProductController').key).to.eql( + 'controllers.ProductController', + ); + }); + + it('throws if there is no base path in the config', async () => { + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/product.model.js'), + 'models/product.model.js', + ); + + await sandbox.writeTextFile( + 'model-endpoints/product.rest-config.js', + ` +const {Product} = require('../models/product.model'); +module.exports = { + model: Product, + pattern: 'CrudRest', + dataSource: 'db', + // basePath not specified +}; + `, + ); + + // Boot the application + await expect(app.boot()).to.be.rejectedWith( + /Missing required field "basePath" in configuration for model Product./, + ); + }); + + it('throws if a Model is used instead of an Entity', async () => { + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/no-entity.model.js'), + 'models/no-entity.model.js', + ); + + await sandbox.writeTextFile( + 'model-endpoints/no-entity.rest-config.js', + ` +const {NoEntity} = require('../models/no-entity.model'); +module.exports = { + // this model extends Model, not Entity + model: NoEntity, + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/no-entities', +}; + `, + ); + + // Boot the application + await expect(app.boot()).to.be.rejectedWith( + /CrudRestController requires a model that extends 'Entity'./, + ); + }); + + class BooterApp extends BootMixin(RepositoryMixin(RestApplication)) { + constructor(options?: ApplicationConfig) { + super(options); + this.projectRoot = sandbox.path; + this.booters(ModelApiBooter); + this.component(CrudRestComponent); + } + } + + async function givenAppWithDataSource() { + app = new BooterApp({ + rest: givenHttpServerConfig(), + }); + app.dataSource(new juggler.DataSource({connector: 'memory'}), 'db'); + } + + async function stopApp() { + if (app?.state === 'started') await app?.stop(); + } +}); diff --git a/packages/rest-crud/src/__tests__/acceptance/booters/model-api.booter.acceptance.ts b/packages/rest-crud/src/__tests__/acceptance/booters/model-api.booter.acceptance.ts new file mode 100644 index 000000000000..09d2eadbeff5 --- /dev/null +++ b/packages/rest-crud/src/__tests__/acceptance/booters/model-api.booter.acceptance.ts @@ -0,0 +1,173 @@ +// Copyright IBM Corp. 2019,2020. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {BootMixin} from '@loopback/boot'; +import {ApplicationConfig} from '@loopback/core'; +import {ModelApiBooter} from '@loopback/model-api-builder'; +import {juggler, RepositoryMixin} from '@loopback/repository'; +import {RestApplication} from '@loopback/rest'; +import { + expect, + givenHttpServerConfig, + TestSandbox, + toJSON, +} from '@loopback/testlab'; +import {resolve} from 'path'; +import {Product} from '../../fixtures/product.model'; +import { + buildCalls, + samePatternBuildCalls, + SamePatternModelApiBuilderComponent, + similarPatternBuildCalls, + SimilarPatternModelApiBuilderComponent, + StubModelApiBuilderComponent, +} from '../../fixtures/stub-model-api-builder'; + +describe('model API booter acceptance tests', () => { + let app: BooterApp; + const sandbox = new TestSandbox(resolve(__dirname, '../../../.sandbox')); + + beforeEach('reset sandbox', () => sandbox.reset()); + beforeEach(givenAppWithDataSource); + + afterEach(stopApp); + + it('uses the correct model API builder', async () => { + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/product.model.js'), + 'models/product.model.js', + ); + + await sandbox.writeTextFile( + 'model-endpoints/product.rest-config.js', + ` +const {Product} = require('../models/product.model'); +module.exports = { + model: Product, + pattern: 'stub', + dataSource: 'db', + basePath: '/products', +}; + `, + ); + + // Boot & start the application + await app.boot(); + await app.start(); + + expect(toJSON(buildCalls)).to.deepEqual( + toJSON([ + { + application: app, + modelClass: Product, + config: { + basePath: '/products', + dataSource: 'db', + pattern: 'stub', + }, + }, + ]), + ); + }); + + it('uses the API builder registered first if there is a duplicate pattern name', async () => { + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/product.model.js'), + 'models/product.model.js', + ); + + await sandbox.writeTextFile( + 'model-endpoints/product.rest-config.js', + ` +const {Product} = require('../models/product.model'); +module.exports = { + model: Product, + pattern: 'same', + dataSource: 'db', + basePath: '/products', +}; + `, + ); + + // Boot & start the application + await app.boot(); + await app.start(); + + // registered first + expect(toJSON(samePatternBuildCalls)).to.eql([toJSON(app)]); + + expect(similarPatternBuildCalls).to.be.empty(); + }); + + it('throws if there are no patterns matching', async () => { + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/product.model.js'), + 'models/product.model.js', + ); + + await sandbox.writeTextFile( + 'model-endpoints/product.rest-config.js', + ` +const {Product} = require('../models/product.model'); +module.exports = { + model: Product, + pattern: 'doesntExist', + dataSource: 'db', + basePath: '/products', +}; + `, + ); + + await expect(app.boot()).to.be.rejectedWith( + /Unsupported API pattern "doesntExist"/, + ); + }); + + it('throws if the model class is invalid', async () => { + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/product.model.js'), + 'models/product.model.js', + ); + + await sandbox.writeTextFile( + 'model-endpoints/product.rest-config.js', + ` +const Product = 'product' +module.exports = { + model: Product, + pattern: 'stub', + dataSource: 'db', + basePath: '/products', +}; + `, + ); + + await expect(app.boot()).to.be.rejectedWith( + /Invalid "model" field\. Expected a Model class, found product/, + ); + }); + + class BooterApp extends BootMixin(RepositoryMixin(RestApplication)) { + constructor(options?: ApplicationConfig) { + super(options); + this.projectRoot = sandbox.path; + this.booters(ModelApiBooter); + this.component(StubModelApiBuilderComponent); + this.component(SamePatternModelApiBuilderComponent); + this.component(SimilarPatternModelApiBuilderComponent); + } + } + + async function givenAppWithDataSource() { + app = new BooterApp({ + rest: givenHttpServerConfig(), + }); + app.dataSource(new juggler.DataSource({connector: 'memory'}), 'db'); + } + + async function stopApp() { + if (app?.state === 'started') await app?.stop(); + } +}); 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 b409babbe794..f50b11b89184 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 @@ -56,7 +56,7 @@ describe('CrudRestController for a simple Product model', () => { Object.freeze(PATCH_DATA); before(setupTestScenario); - after(stopTheApp); + after(stopApp); beforeEach(cleanDatabase); describe('create', () => { @@ -311,8 +311,8 @@ describe('CrudRestController for a simple Product model', () => { client = createRestAppClient(app); } - async function stopTheApp() { - await app.stop(); + async function stopApp() { + if (app?.state === 'started') await app?.stop(); } async function cleanDatabase() { diff --git a/packages/rest-crud/src/__tests__/fixtures/application.ts b/packages/rest-crud/src/__tests__/fixtures/application.ts new file mode 100644 index 000000000000..04f06508a1c7 --- /dev/null +++ b/packages/rest-crud/src/__tests__/fixtures/application.ts @@ -0,0 +1,16 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {BootMixin} from '@loopback/boot'; +import {ApplicationConfig} from '@loopback/core'; +import {RepositoryMixin} from '@loopback/repository'; +import {RestApplication} from '@loopback/rest'; + +export class BooterApp extends BootMixin(RepositoryMixin(RestApplication)) { + constructor(options?: ApplicationConfig) { + super(options); + this.projectRoot = __dirname; + } +} diff --git a/packages/rest-crud/src/__tests__/fixtures/datasource.artifact.ts b/packages/rest-crud/src/__tests__/fixtures/datasource.artifact.ts new file mode 100644 index 000000000000..5390b4b66950 --- /dev/null +++ b/packages/rest-crud/src/__tests__/fixtures/datasource.artifact.ts @@ -0,0 +1,14 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {juggler} from '@loopback/repository'; + +export class DbDataSource extends juggler.DataSource { + static dataSourceName = 'db'; + + constructor() { + super({name: 'db'}); + } +} diff --git a/packages/rest-crud/src/__tests__/fixtures/no-entity.model.ts b/packages/rest-crud/src/__tests__/fixtures/no-entity.model.ts new file mode 100644 index 000000000000..05d310ebc22f --- /dev/null +++ b/packages/rest-crud/src/__tests__/fixtures/no-entity.model.ts @@ -0,0 +1,15 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {model, Model, property} from '@loopback/repository'; + +@model() +export class NoEntity extends Model { + @property({id: true}) + id: number; + + @property({required: true}) + name: string; +} diff --git a/packages/rest-crud/src/__tests__/fixtures/package.json b/packages/rest-crud/src/__tests__/fixtures/package.json new file mode 100644 index 000000000000..24d9646e6635 --- /dev/null +++ b/packages/rest-crud/src/__tests__/fixtures/package.json @@ -0,0 +1,19 @@ +{ + "name": "boot-test-app", + "version": "1.0.0", + "description": "boot-test-app", + "keywords": [ + "loopback-application", + "loopback" + ], + "engines": { + "node": ">=10" + }, + "scripts": { + }, + "repository": { + "type": "git" + }, + "author": "IBM Corp.", + "license": "MIT" +} diff --git a/packages/rest-crud/src/__tests__/fixtures/product.model.ts b/packages/rest-crud/src/__tests__/fixtures/product.model.ts new file mode 100644 index 000000000000..fe11f7cd037c --- /dev/null +++ b/packages/rest-crud/src/__tests__/fixtures/product.model.ts @@ -0,0 +1,15 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class Product extends Entity { + @property({id: true}) + id: number; + + @property({required: true}) + name: string; +} diff --git a/packages/rest-crud/src/__tests__/fixtures/product.repository.ts b/packages/rest-crud/src/__tests__/fixtures/product.repository.ts new file mode 100644 index 000000000000..471151129079 --- /dev/null +++ b/packages/rest-crud/src/__tests__/fixtures/product.repository.ts @@ -0,0 +1,17 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {inject} from '@loopback/core'; +import {DefaultCrudRepository, juggler} from '@loopback/repository'; +import {Product} from './product.model'; + +export class ProductRepository extends DefaultCrudRepository< + Product, + typeof Product.prototype.id +> { + constructor(@inject('datasources.db') dataSource: juggler.DataSource) { + super(Product, dataSource); + } +} diff --git a/packages/rest-crud/src/__tests__/fixtures/stub-model-api-builder.ts b/packages/rest-crud/src/__tests__/fixtures/stub-model-api-builder.ts new file mode 100644 index 000000000000..d5a4052a41b1 --- /dev/null +++ b/packages/rest-crud/src/__tests__/fixtures/stub-model-api-builder.ts @@ -0,0 +1,67 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {bind, Component, createBindingFromClass} from '@loopback/core'; +import { + asModelApiBuilder, + ModelApiBuilder, + ModelApiConfig, +} from '@loopback/model-api-builder'; +import {Model} from '@loopback/rest'; +import {BooterApp} from './application'; + +export const buildCalls: object[] = []; + +@bind(asModelApiBuilder) +class StubModelApiBuilder implements ModelApiBuilder { + readonly pattern: string = 'stub'; + async build( + application: BooterApp, + modelClass: typeof Model & {prototype: Model}, + config: ModelApiConfig, + ): Promise { + buildCalls.push({application, modelClass, config}); + } +} + +export class StubModelApiBuilderComponent implements Component { + bindings = [createBindingFromClass(StubModelApiBuilder)]; +} + +export const samePatternBuildCalls: object[] = []; + +@bind(asModelApiBuilder) +class SamePatternModelApiBuilder implements ModelApiBuilder { + readonly pattern: string = 'same'; + async build( + application: BooterApp, + modelClass: typeof Model & {prototype: Model}, + config: ModelApiConfig, + ): Promise { + samePatternBuildCalls.push(application); + } +} + +export class SamePatternModelApiBuilderComponent implements Component { + bindings = [createBindingFromClass(SamePatternModelApiBuilder)]; +} + +export const similarPatternBuildCalls: object[] = []; + +@bind(asModelApiBuilder) +class SimilarPatternModelApiBuilder implements ModelApiBuilder { + readonly pattern: string = 'same'; + async build( + application: BooterApp, + modelClass: typeof Model & {prototype: Model}, + config: ModelApiConfig, + ): Promise { + similarPatternBuildCalls.push({modelClass}); + } +} + +export class SimilarPatternModelApiBuilderComponent implements Component { + bindings = [createBindingFromClass(SimilarPatternModelApiBuilder)]; +} diff --git a/packages/rest-crud/src/crud-rest.component.ts b/packages/rest-crud/src/crud-rest.component.ts index a067b98811a1..ab4125df85ba 100644 --- a/packages/rest-crud/src/crud-rest.component.ts +++ b/packages/rest-crud/src/crud-rest.component.ts @@ -3,9 +3,17 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Binding, Component, createBindingFromClass} from '@loopback/core'; +import {Booter} from '@loopback/boot'; +import { + Binding, + Component, + Constructor, + createBindingFromClass, +} from '@loopback/core'; +import {ModelApiBooter} from '@loopback/model-api-builder'; import {CrudRestApiBuilder} from './crud-rest.api-builder'; export class CrudRestComponent implements Component { bindings: Binding[] = [createBindingFromClass(CrudRestApiBuilder)]; + booters: Constructor[] = [ModelApiBooter]; } diff --git a/packages/rest-crud/tsconfig.json b/packages/rest-crud/tsconfig.json index e36098a74a4c..0f57aaeca415 100644 --- a/packages/rest-crud/tsconfig.json +++ b/packages/rest-crud/tsconfig.json @@ -10,6 +10,9 @@ "src" ], "references": [ + { + "path": "../boot/tsconfig.json" + }, { "path": "../core/tsconfig.json" },