diff --git a/packages/boot/package.json b/packages/boot/package.json index c4962684b118..f7ee2c60d4a7 100644 --- a/packages/boot/package.json +++ b/packages/boot/package.json @@ -37,6 +37,7 @@ "@loopback/eslint-config": "^5.0.3", "@loopback/openapi-v3": "^2.0.0", "@loopback/rest": "^2.0.0", + "@loopback/rest-crud": "^0.6.6", "@loopback/testlab": "^1.10.3", "@types/node": "^10.17.16" }, diff --git a/packages/boot/src/__tests__/acceptance/crud-rest.api-builder.acceptance.ts b/packages/boot/src/__tests__/acceptance/crud-rest.api-builder.acceptance.ts new file mode 100644 index 000000000000..161437dbefb9 --- /dev/null +++ b/packages/boot/src/__tests__/acceptance/crud-rest.api-builder.acceptance.ts @@ -0,0 +1,172 @@ +// 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 {ApplicationConfig} from '@loopback/core'; +import {juggler, RepositoryMixin} from '@loopback/repository'; +import {RestApplication} from '@loopback/rest'; +import {CrudRestComponent} from '@loopback/rest-crud'; +import {expect, givenHttpServerConfig, TestSandbox} from '@loopback/testlab'; +import {resolve} from 'path'; +import {BootMixin, ModelApiBooter} from '../..'; +import {ProductRepository} from '../fixtures/product.repository'; + +describe('CRUD rest builder acceptance tests', () => { + let app: BooterApp; + const SANDBOX_PATH = resolve(__dirname, '../../.sandbox'); + const sandbox = new TestSandbox(SANDBOX_PATH); + + 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') return; + await app.stop(); + } +}); diff --git a/packages/boot/src/__tests__/fixtures/no-entity.model.ts b/packages/boot/src/__tests__/fixtures/no-entity.model.ts new file mode 100644 index 000000000000..05d310ebc22f --- /dev/null +++ b/packages/boot/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/boot/src/__tests__/fixtures/product.repository.ts b/packages/boot/src/__tests__/fixtures/product.repository.ts new file mode 100644 index 000000000000..471151129079 --- /dev/null +++ b/packages/boot/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/README.md b/packages/rest-crud/README.md index bf73abba0d0b..77b1d4c3ec34 100644 --- a/packages/rest-crud/README.md +++ b/packages/rest-crud/README.md @@ -15,11 +15,60 @@ npm install --save @loopback/rest-crud ## Basic use -`@loopback/rest-crud` exposes two helper methods (`defineCrudRestController` and -`defineCrudRepositoryClass`) for creating controllers and respositories using -code. +`@loopback/rest-crud` can be used along with the built-in `ModelApiBooter` to +easily create a repository class and a controller class for your model. The +following use is a simple approach for this creation, however, you can look at +the "Advanced use" section instead for a more flexible approach. For the examples in the following sections, we are assuming a model named +`Product` and a datasource named `db` have already been created. + +In your `src/application.ts` file: + +```ts +// add the following import +import {CrudRestComponent} from '@loopback/rest-crud'; + +export class TryApplication extends BootMixin( + ServiceMixin(RepositoryMixin(RestApplication)), +) { + constructor(options: ApplicationConfig = {}) { + // other code + + // add the following line + this.component(CrudRestComponent); + } +} +``` + +Create a new file for the configuration, e.g. +`src/model-endpoints/product.rest-config.ts` that defines the `model`, +`pattern`, `dataSource`, and `basePath` properties: + +```ts +import {ModelCrudRestApiConfig} from '@loopback/rest-crud'; +import {Product} from '../models'; + +module.exports = { + model: Product, + pattern: 'CrudRest', // make sure to use this pattern + dataSource: 'db', + basePath: '/products', +}; +``` + +Now your `Product` model will have a default repository and default controller +class defined without the need for a repository or controller class file. + +## Advanced use + +If you would like more flexibility, e.g. if you would only like to define a +default `CrudRest` controller or repository, you can use the two helper methods +(`defineCrudRestController` and `defineCrudRepositoryClass`) exposed from +`@loopback/rest-crud`. These functions will help you create controllers and +respositories using code. + +For the examples in the following sections, we are also assuming a model named `Product`, and a datasource named `db` have already been created. ### Creating a CRUD Controller @@ -37,7 +86,7 @@ endpoints of an existing model with a respository. >(Product, {basePath: '/products'}); ``` -2. Set up dependency injection for the ProductController. +2. Set up dependency injection for the `ProductController`. ```ts inject('repositories.ProductRepository')(ProductController, undefined, 0); @@ -73,10 +122,10 @@ export class TryApplication extends BootMixin( ServiceMixin(RepositoryMixin(RestApplication)), ) { constructor(options: ApplicationConfig = {}) { - ... + // ... } - async boot():Promise { + async boot(): Promise { await super.boot(); const ProductRepository = defineCrudRepositoryClass(Product); @@ -85,9 +134,9 @@ export class TryApplication extends BootMixin( inject('datasources.db')(ProductRepository, undefined, 0); const ProductController = defineCrudRestController< - Product, - typeof Product.prototype.id, - 'id' + Product, + typeof Product.prototype.id, + 'id' >(Product, {basePath: '/products'}); inject(repoBinding.key)(ProductController, undefined, 0); diff --git a/packages/rest-crud/package-lock.json b/packages/rest-crud/package-lock.json index acaab7ccd910..66b915bf3eb0 100644 --- a/packages/rest-crud/package-lock.json +++ b/packages/rest-crud/package-lock.json @@ -4,11 +4,30 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@types/debug": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz", + "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==", + "dev": true + }, "@types/node": { "version": "10.17.16", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.16.tgz", "integrity": "sha512-A4283YSA1OmnIivcpy/4nN86YlnKRiQp8PYwI2KdPCONEBN093QTb0gCtERtkLyVNGKKIGazTZ2nAmVzQU51zA==", "dev": true + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" } } } diff --git a/packages/rest-crud/package.json b/packages/rest-crud/package.json index 3cf76e4309cd..d4b876a06849 100644 --- a/packages/rest-crud/package.json +++ b/packages/rest-crud/package.json @@ -19,16 +19,23 @@ "author": "IBM Corp.", "copyright.owner": "IBM Corp.", "license": "MIT", + "dependencies": { + "@loopback/model-api-builder": "^1.1.3", + "debug": "^4.1.1" + }, "devDependencies": { "@loopback/build": "^1.7.1", + "@loopback/core": "^1.12.4", "@loopback/repository": "^1.19.1", "@loopback/rest": "^2.0.0", "@loopback/testlab": "^1.10.3", - "@types/node": "^10.17.16" + "@types/node": "^10.17.16", + "@types/debug": "^4.1.5" }, "peerDependencies": { - "@loopback/repository": "^1.12.0", - "@loopback/rest": "^1.17.0" + "@loopback/core": "^1.12.4", + "@loopback/repository": "^1.19.1", + "@loopback/rest": "^2.0.0" }, "files": [ "README.md", diff --git a/packages/rest-crud/src/crud-rest.api-builder.ts b/packages/rest-crud/src/crud-rest.api-builder.ts new file mode 100644 index 000000000000..c16625cf7e25 --- /dev/null +++ b/packages/rest-crud/src/crud-rest.api-builder.ts @@ -0,0 +1,140 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/rest-crud +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + bind, + BindingSelector, + Constructor, + ControllerClass, + inject, +} from '@loopback/core'; +import { + asModelApiBuilder, + ModelApiBuilder, + ModelApiConfig, +} from '@loopback/model-api-builder'; +import { + ApplicationWithRepositories, + Class, + Entity, + EntityCrudRepository, +} from '@loopback/repository'; +import {Model} from '@loopback/rest'; +import debugFactory from 'debug'; +import {defineCrudRepositoryClass, defineCrudRestController} from '.'; + +const debug = debugFactory('loopback:boot:crud-rest'); + +export interface ModelCrudRestApiConfig extends ModelApiConfig { + // E.g. '/products' + basePath: string; +} + +@bind(asModelApiBuilder) +export class CrudRestApiBuilder implements ModelApiBuilder { + readonly pattern: string = 'CrudRest'; + + build( + application: ApplicationWithRepositories, + modelClass: typeof Model & {prototype: Model}, + cfg: ModelApiConfig, + ): Promise { + const modelName = modelClass.name; + const config = cfg as ModelCrudRestApiConfig; + if (!config.basePath) { + throw new Error( + `Missing required field "basePath" in configuration for model ${modelName}.`, + ); + } + + if (!(modelClass.prototype instanceof Entity)) { + throw new Error( + `CrudRestController requires a model that extends 'Entity'. (Model name ${modelName} does not extend 'Entity')`, + ); + } + const entityClass = modelClass as typeof Entity & {prototype: Entity}; + + let repoBindingName = `repositories.${entityClass.name}Repository`; + + if (application.isBound(repoBindingName)) { + debug('Using the existing Repository binding %j', repoBindingName); + } else { + // repository class does not exist + const repositoryClass = setupCrudRepository(entityClass, config); + application.repository(repositoryClass); + repoBindingName = repositoryClass.name; + debug('Registered repository class', repoBindingName); + } + + const controllerClass = setupCrudRestController(entityClass, config); + application.controller(controllerClass); + debug('Registered controller class', controllerClass.name); + + return Promise.resolve(); + } +} + +/** + * Set up a CRUD Repository class for the given Entity class. + * + * @param entityClass - the Entity class the repository is built for + * @param config - configuration of the Entity class + */ +function setupCrudRepository( + entityClass: typeof Entity & {prototype: Entity}, + config: ModelCrudRestApiConfig, +): Class> { + const repositoryClass = defineCrudRepositoryClass(entityClass); + + injectFirstConstructorArg( + repositoryClass, + `datasources.${config.dataSource}`, + ); + + return repositoryClass; +} + +/** + * Set up a CRUD Controller class for the given Entity class. + * + * @param entityClass - the Entity class the controller is built for + * @param config - configuration of the Entity class + */ +function setupCrudRestController( + entityClass: typeof Entity & {prototype: Entity}, + config: ModelCrudRestApiConfig, +): ControllerClass { + const controllerClass = defineCrudRestController( + entityClass, + // important - forward the entire config object to allow controller + // factories to accept additional (custom) config options + config, + ); + + injectFirstConstructorArg( + controllerClass, + `repositories.${entityClass.name}Repository`, + ); + + return controllerClass; +} + +/** + * Inject given key into a given class constructor + * + * @param ctor - constructor for a class (e.g. a controller class) + * @param key - binding to use in order to resolve the value of the decorated + * constructor parameter or property + */ +function injectFirstConstructorArg( + ctor: Constructor, + key: BindingSelector, +) { + inject(key)( + ctor, + undefined, // constructor member + 0, // the first argument + ); +} diff --git a/packages/rest-crud/src/crud-rest.component.ts b/packages/rest-crud/src/crud-rest.component.ts new file mode 100644 index 000000000000..1fab5573cf00 --- /dev/null +++ b/packages/rest-crud/src/crud-rest.component.ts @@ -0,0 +1,11 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/rest-crud +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Component, createBindingFromClass} from '@loopback/core'; +import {CrudRestApiBuilder} from './crud-rest.api-builder'; + +export class CrudRestComponent implements Component { + bindings = [createBindingFromClass(CrudRestApiBuilder)]; +} diff --git a/packages/rest-crud/src/index.ts b/packages/rest-crud/src/index.ts index 591fa33f4dfe..33130144140d 100644 --- a/packages/rest-crud/src/index.ts +++ b/packages/rest-crud/src/index.ts @@ -3,5 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +export * from './crud-rest.api-builder'; +export * from './crud-rest.component'; export * from './crud-rest.controller'; export * from './repository-builder';