From ea19129f91a1d5161ef1d97fded0747f8512ec90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 26 Aug 2019 10:35:07 +0200 Subject: [PATCH 01/13] feat: version 1 - feat(booter-rest): initial commit - feat: first draft of the implementation - feat: model-api builder and rest-crud implementation - feat(example-todo): rework the code to use rest-crud component - docs: add more spike docs --- _SPIKE_.md | 163 ++++++++++++++++++ examples/todo/package.json | 4 +- examples/todo/public-models/todo.config.json | 6 + .../src/__tests__/acceptance/test-helper.ts | 4 +- .../__tests__/acceptance/todo.acceptance.ts | 38 +--- examples/todo/src/__tests__/helpers.ts | 35 +--- .../services/geocoder.service.integration.ts | 42 ----- .../unit/controllers/todo.controller.unit.ts | 161 ----------------- examples/todo/src/application.ts | 18 +- examples/todo/src/controllers/index.ts | 6 - .../todo/src/controllers/todo.controller.ts | 138 --------------- examples/todo/src/index.ts | 8 +- examples/todo/src/models/todo.model.ts | 11 -- examples/todo/src/repositories/index.ts | 6 - .../todo/src/repositories/todo.repository.ts | 18 -- .../todo/src/services/geocoder.service.ts | 35 ---- examples/todo/src/services/index.ts | 6 - .../boot/src/booters/base-artifact.booter.ts | 6 +- packages/boot/src/types.ts | 7 + packages/booter-rest/.npmrc | 1 + packages/booter-rest/LICENSE | 25 +++ packages/booter-rest/README.md | 36 ++++ packages/booter-rest/index.d.ts | 6 + packages/booter-rest/index.js | 6 + packages/booter-rest/index.ts | 8 + packages/booter-rest/package-lock.json | 33 ++++ packages/booter-rest/package.json | 55 ++++++ .../acceptance/rest-booter.acceptance.ts | 103 +++++++++++ packages/booter-rest/src/index.ts | 6 + packages/booter-rest/src/rest.booter.ts | 111 ++++++++++++ packages/booter-rest/tsconfig.build.json | 9 + packages/model-api-builder/.npmrc | 1 + packages/model-api-builder/LICENSE | 25 +++ packages/model-api-builder/README.md | 35 ++++ packages/model-api-builder/index.d.ts | 6 + packages/model-api-builder/index.js | 6 + packages/model-api-builder/index.ts | 8 + packages/model-api-builder/package-lock.json | 14 ++ packages/model-api-builder/package.json | 45 +++++ packages/model-api-builder/src/index.ts | 7 + .../src/model-api-builder.ts | 33 ++++ .../model-api-builder/src/model-api-config.ts | 21 +++ .../model-api-builder/tsconfig.build.json | 9 + .../repository/src/mixins/repository.mixin.ts | 21 +++ packages/rest-crud/package-lock.json | 19 ++ packages/rest-crud/package.json | 7 + .../rest-crud/src/crud-rest-builder.plugin.ts | 88 ++++++++++ packages/rest-crud/src/crud-rest.component.ts | 11 ++ .../rest-crud/src/crud-rest.controller.ts | 13 +- packages/rest-crud/src/index.ts | 2 + packages/rest-crud/src/repository-builder.ts | 51 ++++++ 51 files changed, 1029 insertions(+), 504 deletions(-) create mode 100644 _SPIKE_.md create mode 100644 examples/todo/public-models/todo.config.json delete mode 100644 examples/todo/src/__tests__/integration/services/geocoder.service.integration.ts delete mode 100644 examples/todo/src/__tests__/unit/controllers/todo.controller.unit.ts delete mode 100644 examples/todo/src/controllers/index.ts delete mode 100644 examples/todo/src/controllers/todo.controller.ts delete mode 100644 examples/todo/src/repositories/index.ts delete mode 100644 examples/todo/src/repositories/todo.repository.ts delete mode 100644 examples/todo/src/services/geocoder.service.ts delete mode 100644 examples/todo/src/services/index.ts create mode 100644 packages/booter-rest/.npmrc create mode 100644 packages/booter-rest/LICENSE create mode 100644 packages/booter-rest/README.md create mode 100644 packages/booter-rest/index.d.ts create mode 100644 packages/booter-rest/index.js create mode 100644 packages/booter-rest/index.ts create mode 100644 packages/booter-rest/package-lock.json create mode 100644 packages/booter-rest/package.json create mode 100644 packages/booter-rest/src/__tests__/acceptance/rest-booter.acceptance.ts create mode 100644 packages/booter-rest/src/index.ts create mode 100644 packages/booter-rest/src/rest.booter.ts create mode 100644 packages/booter-rest/tsconfig.build.json create mode 100644 packages/model-api-builder/.npmrc create mode 100644 packages/model-api-builder/LICENSE create mode 100644 packages/model-api-builder/README.md create mode 100644 packages/model-api-builder/index.d.ts create mode 100644 packages/model-api-builder/index.js create mode 100644 packages/model-api-builder/index.ts create mode 100644 packages/model-api-builder/package-lock.json create mode 100644 packages/model-api-builder/package.json create mode 100644 packages/model-api-builder/src/index.ts create mode 100644 packages/model-api-builder/src/model-api-builder.ts create mode 100644 packages/model-api-builder/src/model-api-config.ts create mode 100644 packages/model-api-builder/tsconfig.build.json create mode 100644 packages/rest-crud/src/crud-rest-builder.plugin.ts create mode 100644 packages/rest-crud/src/crud-rest.component.ts create mode 100644 packages/rest-crud/src/repository-builder.ts diff --git a/_SPIKE_.md b/_SPIKE_.md new file mode 100644 index 000000000000..221c5358bb3f --- /dev/null +++ b/_SPIKE_.md @@ -0,0 +1,163 @@ +# Booter for creating REST APIs from model files + +Quoting from https://github.com/strongloop/loopback-next/issues/2036: + +> In LoopBack 3, it was very easy to get a fully-featured CRUD REST API with +> very little code: a model definition describing model properties + a model +> configuration specifying which datasource to use. +> +> Let's provide the same simplicity to LB4 users too. +> +> - User creates a model class and uses decorators to define model properties. +> (No change here.) +> - User declaratively defines what kind of data-access patterns to provide +> (CRUD, KeyValue, etc.) and what datasource to use under the hood. +> - `@loopback/boot` processes this configuration and registers appropriate +> repositories & controllers with the app. + +In this Spike, I am demonstrating a PoC implementation of an extensible booter +that processed model configuration files in JSON formats and uses 3rd-party +plugins to build repository & controller classes at runtime. + +The solution has the following high-level parts: + +1. A new package `@loopback/model-api-builder` defines the contract for plugins + (extensions) contributing repository & controller builders. + +2. A new booter `RestBooter` that loads all JSON files from + `/public-models/{model-name}.config.json`, resolves model name into a model + class (via Application context), finds model api builder using + Extension/ExtensionPoint pattern and delegates the remaining work to the + plugin. + +3. An official model-api-builder plugin for building CRUD REST APIs using + `DefaultCrudRepository` implementation. The plugin is implemented inside the + recently introduced package `@loopback/rest-crud`. + +Under the hood, the CRUD REST API builder performs two steps: + +1. It defines a model-specific repository class, e.g. `ProductRepository` and + registers it with the Application context for Dependency Injection. In the + future, we can make this step optional and allow app developers to supply + their own repository class (e.g. as created by `lb4 repository`) 💪 + + The repository class is decorated to receive the dataSource instance via DI. + +2. Then a model-specific controller class is defined, e.g. `ProductController` + and decorated to receive the repository instance via DI. The controller is + registered with the Application context to get it included in the public API. + +I feel it's important to use model-named classes (e.g. `ProductController`) +instead of reusing the same controller/repository class name for all models. +Model-named classes make error troubleshooting and debugging easier, because +stack traces include model name in function names. (Compare +`ProductController.replaceById` with `CrudRestControllerImpl.replaceById` - +which one is more useful?) + +In my proposal, model-config files are in JSON format to make programmatic edits +easier. This has a downside in TypeScript projects - these config files must +live outside `src` because TypeScript does not copy arbitrary JSON files. + +### Extensibility & customization options + +The proposed design enables the following opportunities to extend and customize +the default behavior of API endpoints: + +- App developers can create & bind a custom repository class, this allows them + e.g. to implement functionality similar to LB3 Operation Hooks. + +- App developers can implement their own api-builder plugins, replacing the + repository & controller builders provided by LB4 by their own logic. + +- Model configuration schema is extensible, individual plugins can define + additional model-config options to further tweak the behavior of API + endpoints. + +## How to review the spike + +I have updated `examples/todo` application to leverage this new functionality, +you can look around the updated code to see what the user experience will look +like. Take a look at how acceptance level tests are accessing the repository +created by `rest-crud` plugin. + +The booter is implemented in +[`packages/booter-rest/src/rest.booter.ts`](https://github.com/strongloop/loopback-next/blob/spike/crud-rest-booter/packages/booter-rest/src/rest.booter.ts). +Initially, I created a new package for this booter, but on the second thought, I +think this booter is generic enough to be included in `@loopback/boot` under a +different name `ModelApiBooter`. + +The plugin (extension) contract is defined in two files: + +- [`packages/model-api-builder/src/model-api-builder.ts`](https://github.com/strongloop/loopback-next/blob/spike/crud-rest-booter/packages/model-api-builder/src/model-api-builder.ts) +- [`loopback-next/packages/model-api-builder/src/model-api-config.ts`](https://github.com/strongloop/loopback-next/blob/spike/crud-rest-booter/packages/model-api-builder/src/model-api-config.ts) + +The CRUD REST API builder: +[`packages/rest-crud/src/crud-rest-builder.plugin.ts`](https://github.com/strongloop/loopback-next/blob/spike/crud-rest-booter/packages/rest-crud/src/crud-rest-builder.plugin.ts) + +The remaining changes are small tweaks & improvements of existing packages to +better support this spike. + +## Open questions: + +- Where to keep model config files? + + - `/public-models/product.config.json` (JSON, must be outside src) + - `/src/public-models/product-config.ts` (TS, can be inside src, more + flexible) + +- Load models via DI, or rather let config files to load them via require? + + ```ts + // in src/public-models/product-config.ts + { + model: require('../models/product.model').Product, + // ... + } + ``` + +- If we use TS files, then we can get rid of the extension point too + + ```ts + // in src/public-models/product-config.ts + { + model: require('../models/product.model').Product, + pattern: require('@loopback/rest-crud').CrudRestApiBuilder, + basePath: '/products', + dataSource: 'db', + + // alternatively: + dataSource: require('../datasources/db.datasource').DbDataSource, + } + ``` + +## Tasks + +TBD, this is a preliminary & incomplete list. + +- Add `app.model(Model, name)` API to RepositoryMixin. + + - Do we want to introduce `@model()` decorator for configuring dependency + injection? (Similar to `@repository`.) + - Do we want to rework scaffolded repositories to receive the model class via + DI? + +- Implement model booter to scan `dist/models/**/*.model.js` files and register + them by calling `app.model`. + +- Implement `sandbox.writeJsonFile` in `@loopback/testlab`. + +- Add support for artifact option `rootDir` to `@loopback/boot`. + +- Improve rest-crud to create a named controller class. + +- Improve `@loopback/metadata` and `@loopback/context` per changes made in this + spike + +TBD: stories for the actual implementation + +### Out of scope + +- Infer base path (`/products`) from model name (`Product`). I'd like to + implement this part in the CLI scaffolding model config file. + +- Allow users to supply custom repository class. diff --git a/examples/todo/package.json b/examples/todo/package.json index ab49c854627b..fa9eda11f2e9 100644 --- a/examples/todo/package.json +++ b/examples/todo/package.json @@ -37,19 +37,19 @@ "license": "MIT", "dependencies": { "@loopback/boot": "^1.5.5", + "@loopback/booter-rest": "^0.0.1", "@loopback/context": "^1.22.1", "@loopback/core": "^1.10.1", "@loopback/openapi-v3": "^1.9.6", "@loopback/repository": "^1.13.1", "@loopback/rest": "^1.18.1", + "@loopback/rest-crud": "^0.0.1", "@loopback/rest-explorer": "^1.3.6", - "@loopback/service-proxy": "^1.3.5", "loopback-connector-rest": "^3.4.2" }, "devDependencies": { "@loopback/build": "^2.0.10", "@loopback/eslint-config": "^4.0.2", - "@loopback/http-caching-proxy": "^1.1.12", "@loopback/testlab": "^1.8.0", "@types/lodash": "^4.14.138", "@types/node": "^10.14.17", diff --git a/examples/todo/public-models/todo.config.json b/examples/todo/public-models/todo.config.json new file mode 100644 index 000000000000..7271f3f4efc1 --- /dev/null +++ b/examples/todo/public-models/todo.config.json @@ -0,0 +1,6 @@ +{ + "model": "Todo", + "pattern": "CrudRest", + "dataSource": "db", + "basePath": "/todos" +} diff --git a/examples/todo/src/__tests__/acceptance/test-helper.ts b/examples/todo/src/__tests__/acceptance/test-helper.ts index 1806e3b1185d..eeba1d329a66 100644 --- a/examples/todo/src/__tests__/acceptance/test-helper.ts +++ b/examples/todo/src/__tests__/acceptance/test-helper.ts @@ -3,12 +3,12 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {TodoListApplication} from '../..'; import { + Client, createRestAppClient, givenHttpServerConfig, - Client, } from '@loopback/testlab'; +import {TodoListApplication} from '../..'; export async function setupApplication(): Promise { const app = new TodoListApplication({ diff --git a/examples/todo/src/__tests__/acceptance/todo.acceptance.ts b/examples/todo/src/__tests__/acceptance/todo.acceptance.ts index 958903f271a4..f89a7f24a328 100644 --- a/examples/todo/src/__tests__/acceptance/todo.acceptance.ts +++ b/examples/todo/src/__tests__/acceptance/todo.acceptance.ts @@ -13,24 +13,13 @@ import { } from '@loopback/testlab'; import {TodoListApplication} from '../../application'; import {Todo} from '../../models/'; -import {TodoRepository} from '../../repositories/'; -import { - aLocation, - getProxiedGeoCoderConfig, - givenCachingProxy, - givenTodo, - HttpCachingProxy, -} from '../helpers'; +import {givenTodo, TodoRepository} from '../helpers'; describe('TodoApplication', () => { let app: TodoListApplication; let client: Client; let todoRepo: TodoRepository; - let cachingProxy: HttpCachingProxy; - before(async () => (cachingProxy = await givenCachingProxy())); - after(() => cachingProxy.stop()); - before(givenRunningApplicationWithCustomConfiguration); after(() => app.stop()); @@ -76,24 +65,6 @@ describe('TodoApplication', () => { .expect(422); }); - it('creates an address-based reminder', async function() { - // Increase the timeout to accommodate slow network connections - // eslint-disable-next-line no-invalid-this - this.timeout(30000); - - const todo = givenTodo({remindAtAddress: aLocation.address}); - const response = await client - .post('/todos') - .send(todo) - .expect(200); - todo.remindAtGeo = aLocation.geostring; - - expect(response.body).to.containEql(todo); - - const result = await todoRepo.findById(response.body.id); - expect(result).to.containEql(todo); - }); - context('when dealing with a single persisted todo', () => { let persistedTodo: Todo; @@ -209,17 +180,12 @@ describe('TodoApplication', () => { connector: 'memory', }); - // Override Geocoder datasource to use a caching proxy to speed up tests. - app - .bind('datasources.config.geocoder') - .to(getProxiedGeoCoderConfig(cachingProxy)); - // Start Application await app.start(); } async function givenTodoRepository() { - todoRepo = await app.getRepository(TodoRepository); + todoRepo = await app.get('repositories.TodoRepository'); } async function givenTodoInstance(todo?: Partial) { diff --git a/examples/todo/src/__tests__/helpers.ts b/examples/todo/src/__tests__/helpers.ts index 76bdceca5ff6..56e137ea16a3 100644 --- a/examples/todo/src/__tests__/helpers.ts +++ b/examples/todo/src/__tests__/helpers.ts @@ -3,12 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {HttpCachingProxy} from '@loopback/http-caching-proxy'; -import {merge} from 'lodash'; -import * as path from 'path'; -import * as GEO_CODER_CONFIG from '../datasources/geocoder.datasource.json'; +import {EntityCrudRepository} from '@loopback/repository'; import {Todo} from '../models/index'; -import {GeoPoint} from '../services/geocoder.service'; /* ============================================================================== @@ -46,28 +42,7 @@ export function givenTodo(todo?: Partial) { return new Todo(data); } -export const aLocation = { - address: '1 New Orchard Road, Armonk, 10504', - geopoint: {y: 41.109653, x: -73.72467}, - get geostring() { - return `${this.geopoint.y},${this.geopoint.x}`; - }, -}; - -export function getProxiedGeoCoderConfig(proxy: HttpCachingProxy) { - return merge({}, GEO_CODER_CONFIG, { - options: { - proxy: proxy.url, - tunnel: false, - }, - }); -} - -export {HttpCachingProxy}; -export async function givenCachingProxy() { - const proxy = new HttpCachingProxy({ - cachePath: path.resolve(__dirname, '.http-cache'), - }); - await proxy.start(); - return proxy; -} +export type TodoRepository = EntityCrudRepository< + Todo, + typeof Todo.prototype.id +>; diff --git a/examples/todo/src/__tests__/integration/services/geocoder.service.integration.ts b/examples/todo/src/__tests__/integration/services/geocoder.service.integration.ts deleted file mode 100644 index a4cdacffb877..000000000000 --- a/examples/todo/src/__tests__/integration/services/geocoder.service.integration.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright IBM Corp. 2019. All Rights Reserved. -// Node module: @loopback/example-todo -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import {expect} from '@loopback/testlab'; -import {GeocoderDataSource} from '../../../datasources/geocoder.datasource'; -import {GeocoderService, GeocoderServiceProvider} from '../../../services'; -import { - getProxiedGeoCoderConfig, - givenCachingProxy, - HttpCachingProxy, -} from '../../helpers'; - -describe('GeoLookupService', function() { - // eslint-disable-next-line no-invalid-this - this.timeout(30 * 1000); - - let cachingProxy: HttpCachingProxy; - before(async () => (cachingProxy = await givenCachingProxy())); - after(() => cachingProxy.stop()); - - let service: GeocoderService; - before(givenGeoService); - - it('resolves an address to a geo point', async () => { - const points = await service.geocode('1 New Orchard Road, Armonk, 10504'); - - expect(points).to.deepEqual([ - { - y: 41.109653, - x: -73.72467, - }, - ]); - }); - - async function givenGeoService() { - const config = getProxiedGeoCoderConfig(cachingProxy); - const dataSource = new GeocoderDataSource(config); - service = await new GeocoderServiceProvider(dataSource).value(); - } -}); diff --git a/examples/todo/src/__tests__/unit/controllers/todo.controller.unit.ts b/examples/todo/src/__tests__/unit/controllers/todo.controller.unit.ts deleted file mode 100644 index 50f575291b06..000000000000 --- a/examples/todo/src/__tests__/unit/controllers/todo.controller.unit.ts +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright IBM Corp. 2019. All Rights Reserved. -// Node module: @loopback/example-todo -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import {Filter} from '@loopback/repository'; -import { - createStubInstance, - expect, - sinon, - StubbedInstanceWithSinonAccessor, -} from '@loopback/testlab'; -import {TodoController} from '../../../controllers'; -import {Todo} from '../../../models/index'; -import {TodoRepository} from '../../../repositories'; -import {GeocoderService} from '../../../services'; -import {aLocation, givenTodo} from '../../helpers'; - -describe('TodoController', () => { - let todoRepo: StubbedInstanceWithSinonAccessor; - let geoService: GeocoderService; - - let geocode: sinon.SinonStub; - - /* - ============================================================================= - TEST VARIABLES - Combining top-level objects with our resetRepositories method means we don't - need to duplicate several variable assignments (and generation statements) - in all of our test logic. - - NOTE: If you wanted to parallelize your test runs, you should avoid this - pattern since each of these tests is sharing references. - ============================================================================= - */ - let controller: TodoController; - let aTodo: Todo; - let aTodoWithId: Todo; - let aChangedTodo: Todo; - let aListOfTodos: Todo[]; - - beforeEach(resetRepositories); - - describe('createTodo', () => { - it('creates a Todo', async () => { - const create = todoRepo.stubs.create; - create.resolves(aTodoWithId); - const result = await controller.createTodo(aTodo); - expect(result).to.eql(aTodoWithId); - sinon.assert.calledWith(create, aTodo); - }); - - it('resolves remindAtAddress to a geocode', async () => { - const create = todoRepo.stubs.create; - geocode.resolves([aLocation.geopoint]); - - const input = givenTodo({remindAtAddress: aLocation.address}); - - const expected = new Todo(input); - Object.assign(expected, { - remindAtAddress: aLocation.address, - remindAtGeo: aLocation.geostring, - }); - create.resolves(expected); - - const result = await controller.createTodo(input); - - expect(result).to.eql(expected); - sinon.assert.calledWith(create, input); - sinon.assert.calledWith(geocode, input.remindAtAddress); - }); - }); - - describe('findTodoById', () => { - it('returns a todo if it exists', async () => { - const findById = todoRepo.stubs.findById; - findById.resolves(aTodoWithId); - expect(await controller.findTodoById(aTodoWithId.id as number)).to.eql( - aTodoWithId, - ); - sinon.assert.calledWith(findById, aTodoWithId.id); - }); - }); - - describe('findTodos', () => { - it('returns multiple todos if they exist', async () => { - const find = todoRepo.stubs.find; - find.resolves(aListOfTodos); - expect(await controller.findTodos()).to.eql(aListOfTodos); - sinon.assert.called(find); - }); - - it('returns empty list if no todos exist', async () => { - const find = todoRepo.stubs.find; - const expected: Todo[] = []; - find.resolves(expected); - expect(await controller.findTodos()).to.eql(expected); - sinon.assert.called(find); - }); - - it('uses the provided filter', async () => { - const find = todoRepo.stubs.find; - const filter: Filter = {where: {isComplete: false}}; - - find.resolves(aListOfTodos); - await controller.findTodos(filter); - sinon.assert.calledWith(find, filter); - }); - }); - - describe('replaceTodo', () => { - it('successfully replaces existing items', async () => { - const replaceById = todoRepo.stubs.replaceById; - replaceById.resolves(); - await controller.replaceTodo(aTodoWithId.id as number, aChangedTodo); - sinon.assert.calledWith(replaceById, aTodoWithId.id, aChangedTodo); - }); - }); - - describe('updateTodo', () => { - it('successfully updates existing items', async () => { - const updateById = todoRepo.stubs.updateById; - updateById.resolves(); - await controller.updateTodo(aTodoWithId.id as number, aChangedTodo); - sinon.assert.calledWith(updateById, aTodoWithId.id, aChangedTodo); - }); - }); - - describe('deleteTodo', () => { - it('successfully deletes existing items', async () => { - const deleteById = todoRepo.stubs.deleteById; - deleteById.resolves(); - await controller.deleteTodo(aTodoWithId.id as number); - sinon.assert.calledWith(deleteById, aTodoWithId.id); - }); - }); - - function resetRepositories() { - todoRepo = createStubInstance(TodoRepository); - aTodo = givenTodo(); - aTodoWithId = givenTodo({ - id: 1, - }); - aListOfTodos = [ - aTodoWithId, - givenTodo({ - id: 2, - title: 'so many things to do', - }), - ] as Todo[]; - aChangedTodo = givenTodo({ - id: aTodoWithId.id, - title: 'Do some important things', - }); - - geoService = {geocode: sinon.stub()}; - geocode = geoService.geocode as sinon.SinonStub; - - controller = new TodoController(todoRepo, geoService); - } -}); diff --git a/examples/todo/src/application.ts b/examples/todo/src/application.ts index 9ee0dc685d64..9cbbc0afbf20 100644 --- a/examples/todo/src/application.ts +++ b/examples/todo/src/application.ts @@ -4,16 +4,18 @@ // License text available at https://opensource.org/licenses/MIT import {BootMixin} from '@loopback/boot'; +import {RestBooter} from '@loopback/booter-rest'; import {ApplicationConfig} from '@loopback/core'; -import {RestExplorerComponent} from '@loopback/rest-explorer'; import {RepositoryMixin} from '@loopback/repository'; import {RestApplication} from '@loopback/rest'; -import {ServiceMixin} from '@loopback/service-proxy'; +import {CrudRestComponent} from '@loopback/rest-crud'; +import {RestExplorerComponent} from '@loopback/rest-explorer'; import * as path from 'path'; +import {Todo} from './models'; import {MySequence} from './sequence'; export class TodoListApplication extends BootMixin( - ServiceMixin(RepositoryMixin(RestApplication)), + RepositoryMixin(RestApplication), ) { constructor(options: ApplicationConfig = {}) { super(options); @@ -26,6 +28,9 @@ export class TodoListApplication extends BootMixin( this.component(RestExplorerComponent); + this.component(CrudRestComponent); + this.booters(RestBooter); + this.projectRoot = __dirname; // Customize @loopback/boot Booter Conventions here this.bootOptions = { @@ -37,4 +42,11 @@ export class TodoListApplication extends BootMixin( }, }; } + + async boot(): Promise { + // temporary workaround for missing Model booter + this.model(Todo); + + return super.boot(); + } } diff --git a/examples/todo/src/controllers/index.ts b/examples/todo/src/controllers/index.ts deleted file mode 100644 index 72f6ff506fbc..000000000000 --- a/examples/todo/src/controllers/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/example-todo -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -export * from './todo.controller'; diff --git a/examples/todo/src/controllers/todo.controller.ts b/examples/todo/src/controllers/todo.controller.ts deleted file mode 100644 index cae69a7d1619..000000000000 --- a/examples/todo/src/controllers/todo.controller.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/example-todo -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import {inject} from '@loopback/core'; -import {Filter, repository} from '@loopback/repository'; -import { - del, - get, - getFilterSchemaFor, - getModelSchemaRef, - param, - patch, - post, - put, - requestBody, -} from '@loopback/rest'; -import {Todo} from '../models'; -import {TodoRepository} from '../repositories'; -import {GeocoderService} from '../services'; - -export class TodoController { - constructor( - @repository(TodoRepository) protected todoRepo: TodoRepository, - @inject('services.GeocoderService') protected geoService: GeocoderService, - ) {} - - @post('/todos', { - responses: { - '200': { - description: 'Todo model instance', - content: {'application/json': {schema: getModelSchemaRef(Todo)}}, - }, - }, - }) - async createTodo( - @requestBody({ - content: { - 'application/json': { - schema: getModelSchemaRef(Todo, {exclude: ['id']}), - }, - }, - }) - todo: Omit, - ): Promise { - if (todo.remindAtAddress) { - // TODO(bajtos) handle "address not found" - const geo = await this.geoService.geocode(todo.remindAtAddress); - // Encode the coordinates as "lat,lng" (Google Maps API format). See also - // https://stackoverflow.com/q/7309121/69868 - // https://gis.stackexchange.com/q/7379 - // eslint-disable-next-line require-atomic-updates - todo.remindAtGeo = `${geo[0].y},${geo[0].x}`; - } - return this.todoRepo.create(todo); - } - - @get('/todos/{id}', { - responses: { - '200': { - description: 'Todo model instance', - content: {'application/json': {schema: getModelSchemaRef(Todo)}}, - }, - }, - }) - async findTodoById( - @param.path.number('id') id: number, - @param.query.boolean('items') items?: boolean, - ): Promise { - return this.todoRepo.findById(id); - } - - @get('/todos', { - responses: { - '200': { - description: 'Array of Todo model instances', - content: { - 'application/json': { - schema: {type: 'array', items: getModelSchemaRef(Todo)}, - }, - }, - }, - }, - }) - async findTodos( - @param.query.object('filter', getFilterSchemaFor(Todo)) - filter?: Filter, - ): Promise { - return this.todoRepo.find(filter); - } - - @put('/todos/{id}', { - responses: { - '204': { - description: 'Todo PUT success', - }, - }, - }) - async replaceTodo( - @param.path.number('id') id: number, - @requestBody() todo: Todo, - ): Promise { - await this.todoRepo.replaceById(id, todo); - } - - @patch('/todos/{id}', { - responses: { - '204': { - description: 'Todo PATCH success', - }, - }, - }) - async updateTodo( - @param.path.number('id') id: number, - @requestBody({ - content: { - 'application/json': { - schema: getModelSchemaRef(Todo, {partial: true}), - }, - }, - }) - todo: Partial, - ): Promise { - await this.todoRepo.updateById(id, todo); - } - - @del('/todos/{id}', { - responses: { - '204': { - description: 'Todo DELETE success', - }, - }, - }) - async deleteTodo(@param.path.number('id') id: number): Promise { - await this.todoRepo.deleteById(id); - } -} diff --git a/examples/todo/src/index.ts b/examples/todo/src/index.ts index 0eacdcec766e..74832c1a9a79 100644 --- a/examples/todo/src/index.ts +++ b/examples/todo/src/index.ts @@ -3,8 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {TodoListApplication} from './application'; import {ApplicationConfig} from '@loopback/core'; +import {TodoListApplication} from './application'; export async function main(options: ApplicationConfig = {}) { const app = new TodoListApplication(options); @@ -17,8 +17,6 @@ export async function main(options: ApplicationConfig = {}) { } // re-exports for our benchmark, not needed for the tutorial itself -export {TodoListApplication}; - -export * from './models'; -export * from './repositories'; export * from '@loopback/rest'; +export * from './models'; +export {TodoListApplication}; diff --git a/examples/todo/src/models/todo.model.ts b/examples/todo/src/models/todo.model.ts index 93ed89c900a6..452ea959af80 100644 --- a/examples/todo/src/models/todo.model.ts +++ b/examples/todo/src/models/todo.model.ts @@ -29,17 +29,6 @@ export class Todo extends Entity { }) isComplete?: boolean; - @property({ - type: 'string', - }) - remindAtAddress?: string; // address,city,zipcode - - // TODO(bajtos) Use LoopBack's GeoPoint type here - @property({ - type: 'string', - }) - remindAtGeo?: string; // latitude,longitude - constructor(data?: Partial) { super(data); } diff --git a/examples/todo/src/repositories/index.ts b/examples/todo/src/repositories/index.ts deleted file mode 100644 index 37912821877a..000000000000 --- a/examples/todo/src/repositories/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/example-todo -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -export * from './todo.repository'; diff --git a/examples/todo/src/repositories/todo.repository.ts b/examples/todo/src/repositories/todo.repository.ts deleted file mode 100644 index 4693c6df21b5..000000000000 --- a/examples/todo/src/repositories/todo.repository.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright IBM Corp. 2018,2019. All Rights Reserved. -// Node module: @loopback/example-todo -// 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 {Todo, TodoRelations} from '../models'; - -export class TodoRepository extends DefaultCrudRepository< - Todo, - typeof Todo.prototype.id, - TodoRelations -> { - constructor(@inject('datasources.db') dataSource: juggler.DataSource) { - super(Todo, dataSource); - } -} diff --git a/examples/todo/src/services/geocoder.service.ts b/examples/todo/src/services/geocoder.service.ts deleted file mode 100644 index fe1c6113ba9f..000000000000 --- a/examples/todo/src/services/geocoder.service.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/example-todo -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import {getService, juggler} from '@loopback/service-proxy'; -import {inject, Provider} from '@loopback/core'; -import {GeocoderDataSource} from '../datasources/geocoder.datasource'; - -export interface GeoPoint { - /** - * latitude - */ - y: number; - - /** - * longitude - */ - x: number; -} - -export interface GeocoderService { - geocode(address: string): Promise; -} - -export class GeocoderServiceProvider implements Provider { - constructor( - @inject('datasources.geocoder') - protected dataSource: juggler.DataSource = new GeocoderDataSource(), - ) {} - - value(): Promise { - return getService(this.dataSource); - } -} diff --git a/examples/todo/src/services/index.ts b/examples/todo/src/services/index.ts deleted file mode 100644 index db603dc8f7c9..000000000000 --- a/examples/todo/src/services/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/example-todo -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -export * from './geocoder.service'; diff --git a/packages/boot/src/booters/base-artifact.booter.ts b/packages/boot/src/booters/base-artifact.booter.ts index 3709497f4d90..986ca7cffc2d 100644 --- a/packages/boot/src/booters/base-artifact.booter.ts +++ b/packages/boot/src/booters/base-artifact.booter.ts @@ -36,7 +36,7 @@ export class BaseArtifactBooter implements Booter { /** * Project root relative to which all other paths are resolved */ - readonly projectRoot: string; + projectRoot: string; /** * Relative paths of directories to be searched */ @@ -79,6 +79,10 @@ export class BaseArtifactBooter implements Booter { * NOTE: All properties are configured even if all aren't used. */ async configure() { + if (this.options.rootDir) { + this.projectRoot = path.resolve(this.projectRoot, this.options.rootDir); + } + this.dirs = this.options.dirs ? Array.isArray(this.options.dirs) ? this.options.dirs diff --git a/packages/boot/src/types.ts b/packages/boot/src/types.ts index 64c0566a0be4..26b94a2e8491 100644 --- a/packages/boot/src/types.ts +++ b/packages/boot/src/types.ts @@ -11,6 +11,13 @@ import {BootTags} from './keys'; * this Booter. */ export type ArtifactOptions = { + /** + * Modify the rootDir provided by the application. Useful to escape `dist` + * directory and load artifacts from the project root. + * Example value: `../`; + */ + rootDir?: string; + /** * Array of directories to check for artifacts. * Paths must be relative. Defaults to ['controllers'] diff --git a/packages/booter-rest/.npmrc b/packages/booter-rest/.npmrc new file mode 100644 index 000000000000..cafe685a112d --- /dev/null +++ b/packages/booter-rest/.npmrc @@ -0,0 +1 @@ +package-lock=true diff --git a/packages/booter-rest/LICENSE b/packages/booter-rest/LICENSE new file mode 100644 index 000000000000..6d24a116f50b --- /dev/null +++ b/packages/booter-rest/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2019. All Rights Reserved. +Node module: @loopback/booter-rest +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/booter-rest/README.md b/packages/booter-rest/README.md new file mode 100644 index 000000000000..9cc15080256d --- /dev/null +++ b/packages/booter-rest/README.md @@ -0,0 +1,36 @@ +# @loopback/booter-rest + +REST API controller implementing default CRUD semantics. + +## Overview + +This module allows applications to quickly expose a model via REST API without +having to implement a custom controller class. + +## Installation + +```sh +npm install --save @loopback/booter-rest +``` + +## Basic use + +TBD + +## Contributions + +- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md) +- [Join the team](https://github.com/strongloop/loopback-next/issues/110) + +## Tests + +Run `npm test` from the root folder. + +## Contributors + +See +[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +## License + +MIT diff --git a/packages/booter-rest/index.d.ts b/packages/booter-rest/index.d.ts new file mode 100644 index 000000000000..e40f802c2eae --- /dev/null +++ b/packages/booter-rest/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/booter-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/packages/booter-rest/index.js b/packages/booter-rest/index.js new file mode 100644 index 000000000000..0bed8e5b21a7 --- /dev/null +++ b/packages/booter-rest/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/booter-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +module.exports = require('./dist'); diff --git a/packages/booter-rest/index.ts b/packages/booter-rest/index.ts new file mode 100644 index 000000000000..f99bc4447f8d --- /dev/null +++ b/packages/booter-rest/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/booter-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// DO NOT EDIT THIS FILE +// Add any additional (re)exports to src/index.ts instead. +export * from './src'; diff --git a/packages/booter-rest/package-lock.json b/packages/booter-rest/package-lock.json new file mode 100644 index 000000000000..2333a2e75d68 --- /dev/null +++ b/packages/booter-rest/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "@loopback/booter-rest", + "version": "0.0.1", + "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.14.16", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.16.tgz", + "integrity": "sha512-/opXIbfn0P+VLt+N8DE4l8Mn8rbhiJgabU96ZJ0p9mxOkIks5gh6RUnpHak7Yh0SFkyjO/ODbxsQQPV2bpMmyA==", + "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/booter-rest/package.json b/packages/booter-rest/package.json new file mode 100644 index 000000000000..8794d8d478b9 --- /dev/null +++ b/packages/booter-rest/package.json @@ -0,0 +1,55 @@ +{ + "name": "@loopback/booter-rest", + "version": "0.0.1", + "description": "REST API controller implementing default CRUD semantics", + "engines": { + "node": ">=8.9" + }, + "main": "index", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "lb-tsc", + "clean": "lb-clean loopback-booter-rest*.tgz dist tsconfig.build.tsbuildinfo package", + "pretest": "npm run build", + "test": "lb-mocha \"dist/__tests__/**/*.js\"", + "verify": "npm pack && tar xf loopback-booter-rest*.tgz && tree package && npm run clean" + }, + "author": "IBM Corp.", + "copyright.owner": "IBM Corp.", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + }, + "peerDependencies": { + "@loopback/boot": "^1.5.3", + "@loopback/core": "^1.9.3", + "@loopback/rest": "^1.17.0" + }, + "devDependencies": { + "@loopback/boot": "^1.5.3", + "@loopback/model-api-builder": "^0.0.1", + "@loopback/build": "^1.7.1", + "@loopback/core": "^1.9.3", + "@loopback/repository": "^1.12.0", + "@loopback/rest": "^1.17.0", + "@loopback/rest-crud": "^0.0.1", + "@loopback/testlab": "^1.7.4", + "@types/debug": "^4.1.5", + "@types/node": "^10.14.15" + }, + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist", + "src", + "!*/__tests__" + ], + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git", + "directory": "packages/booter-rest" + } +} diff --git a/packages/booter-rest/src/__tests__/acceptance/rest-booter.acceptance.ts b/packages/booter-rest/src/__tests__/acceptance/rest-booter.acceptance.ts new file mode 100644 index 000000000000..81169773a570 --- /dev/null +++ b/packages/booter-rest/src/__tests__/acceptance/rest-booter.acceptance.ts @@ -0,0 +1,103 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/booter-rest +// 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 { + DefaultCrudRepository, + Entity, + juggler, + model, + property, + RepositoryMixin, +} from '@loopback/repository'; +import {RestApplication} from '@loopback/rest'; +import {CrudRestComponent} from '@loopback/rest-crud'; +import { + createRestAppClient, + expect, + givenHttpServerConfig, + TestSandbox, + toJSON, +} from '@loopback/testlab'; +import {resolve} from 'path'; +import {RestBooter} from '../../rest.booter'; + +describe('rest booter 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('exposes models via CRUD REST API', async () => { + // Define the model. While we could do this via ModelBooter, it's usually + // easier to do so directly from code - the test is easier to read. + @model() + class Product extends Entity { + @property({id: true}) + id: number; + + @property({required: true}) + name: string; + } + app.model(Product); + + // Write model-config file to specify how to expose the model via API + await sandbox.writeJsonFile('public-models/product.config.json', { + model: 'Product', + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/products', + }); + + await app.boot(); + await app.start(); + + const client = createRestAppClient(app); + + const created = await client + .post('/products') + .send({name: 'a name'}) + .expect(200); + const found = await client.get('/products').expect(200); + expect(found.body).to.deepEqual([{id: created.body.id, name: 'a name'}]); + + // verify that we have a repository class to use e.g. in tests + const repo = await app.get< + DefaultCrudRepository + >('repositories.ProductRepository'); + const stored = await repo.find(); + expect(toJSON(stored)).to.deepEqual(found.body); + }); + + class BooterApp extends BootMixin(RepositoryMixin(RestApplication)) { + constructor(options?: ApplicationConfig) { + super(options); + this.projectRoot = sandbox.path + '/dist'; + this.booters(RestBooter); + this.component(CrudRestComponent); + } + } + + async function givenAppWithDataSource() { + await sandbox.mkdir('dist'); + app = new BooterApp({ + rest: givenHttpServerConfig(), + }); + app.dataSource(new juggler.DataSource({connector: 'memory'}), 'db'); + } + + async function stopApp() { + try { + await app.stop(); + } catch (err) { + console.error('Cannot stop the app, ignoring the error.', err); + } + } +}); diff --git a/packages/booter-rest/src/index.ts b/packages/booter-rest/src/index.ts new file mode 100644 index 000000000000..68d0ad395540 --- /dev/null +++ b/packages/booter-rest/src/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/booter-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './rest.booter'; diff --git a/packages/booter-rest/src/rest.booter.ts b/packages/booter-rest/src/rest.booter.ts new file mode 100644 index 000000000000..e691d818d35c --- /dev/null +++ b/packages/booter-rest/src/rest.booter.ts @@ -0,0 +1,111 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/booter-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + ArtifactOptions, + BaseArtifactBooter, + BootBindings, + booter, +} from '@loopback/boot'; +import { + config, + CoreBindings, + extensionPoint, + extensions, + Getter, + inject, +} from '@loopback/core'; +import { + ModelApiBuilder, + MODEL_API_BUILDER_PLUGINS, +} from '@loopback/model-api-builder'; +import {ApplicationWithRepositories, Model} from '@loopback/repository'; +import * as debugFactory from 'debug'; +import * as fs from 'fs'; +import * as path from 'path'; +import {promisify} from 'util'; + +const debug = debugFactory('loopback:boot:rest-booter'); +const readFile = promisify(fs.readFile); + +@booter('rest') +@extensionPoint(MODEL_API_BUILDER_PLUGINS) +export class RestBooter extends BaseArtifactBooter { + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) + public app: ApplicationWithRepositories, + @inject(BootBindings.PROJECT_ROOT) projectRoot: string, + @extensions() + public getModelApiBuilders: Getter, + @config() + public booterConfig: ArtifactOptions = {}, + ) { + // TODO assert that `app` has RepositoryMixin members + + super( + projectRoot, + // Set booter options if passed in via bootConfig + Object.assign({}, RestDefaults, booterConfig), + ); + } + + async load(): Promise { + // Important: don't call `super.load()` here, it would try to load + // classes via `loadClassesFromFiles` - that won't work for JSON files + await Promise.all( + this.discovered.map(async f => { + try { + // It's important to await before returning, + // otherwise the catch block won't receive errors + await this.setupModel(f); + } catch (err) { + const shortPath = path.relative(this.projectRoot, f); + err.message += ` (while loading ${shortPath})`; + throw err; + } + }), + ); + } + + async setupModel(configFile: string): Promise { + const cfg = JSON.parse(await readFile(configFile, {encoding: 'utf-8'})); + debug( + 'Loaded model config from %s', + path.relative(this.projectRoot, configFile), + cfg, + ); + + const modelClass = await this.app.get( + `models.${cfg.model}`, + ); + + const builder = await this.getApiBuilderForPattern(cfg.pattern); + await builder.setup(this.app, modelClass, cfg); + } + + async getApiBuilderForPattern(pattern: string): Promise { + const allBuilders = await this.getModelApiBuilders(); + const builder = allBuilders.find(b => b.pattern === pattern); + if (!builder) { + const availableBuilders = allBuilders.map(b => b.pattern).join(', '); + throw new Error( + `Unsupported API pattern "${pattern}". ` + + `Available patterns: ${availableBuilders || ''}`, + ); + } + return builder; + } +} + +/** + * Default ArtifactOptions for ControllerBooter. + */ +export const RestDefaults: ArtifactOptions = { + // public-models should live outside of "dist" + rootDir: '../', + dirs: ['public-models'], + extensions: ['.config.json'], + nested: true, +}; diff --git a/packages/booter-rest/tsconfig.build.json b/packages/booter-rest/tsconfig.build.json new file mode 100644 index 000000000000..c7b8e49eaca5 --- /dev/null +++ b/packages/booter-rest/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@loopback/build/config/tsconfig.common.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/model-api-builder/.npmrc b/packages/model-api-builder/.npmrc new file mode 100644 index 000000000000..cafe685a112d --- /dev/null +++ b/packages/model-api-builder/.npmrc @@ -0,0 +1 @@ +package-lock=true diff --git a/packages/model-api-builder/LICENSE b/packages/model-api-builder/LICENSE new file mode 100644 index 000000000000..82171ac0c01a --- /dev/null +++ b/packages/model-api-builder/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2019. All Rights Reserved. +Node module: @loopback/model-api-builder +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/model-api-builder/README.md b/packages/model-api-builder/README.md new file mode 100644 index 000000000000..0f70dd248f3d --- /dev/null +++ b/packages/model-api-builder/README.md @@ -0,0 +1,35 @@ +# @loopback/rest-builder-plugin + +Types and helpers for packages contributing Model API builders. + +## Overview + +TBD + +## Installation + +```sh +npm install --save @loopback/rest-builder-plugin +``` + +## Basic use + +TBD + +## Contributions + +- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md) +- [Join the team](https://github.com/strongloop/loopback-next/issues/110) + +## Tests + +Run `npm test` from the root folder. + +## Contributors + +See +[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +## License + +MIT diff --git a/packages/model-api-builder/index.d.ts b/packages/model-api-builder/index.d.ts new file mode 100644 index 000000000000..c234228b19d3 --- /dev/null +++ b/packages/model-api-builder/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/model-api-builder +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/packages/model-api-builder/index.js b/packages/model-api-builder/index.js new file mode 100644 index 000000000000..3f6822487e92 --- /dev/null +++ b/packages/model-api-builder/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/rest-builder-plugin +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +module.exports = require('./dist'); diff --git a/packages/model-api-builder/index.ts b/packages/model-api-builder/index.ts new file mode 100644 index 000000000000..087505382f3b --- /dev/null +++ b/packages/model-api-builder/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/model-api-builder +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// DO NOT EDIT THIS FILE +// Add any additional (re)exports to src/index.ts instead. +export * from './src'; diff --git a/packages/model-api-builder/package-lock.json b/packages/model-api-builder/package-lock.json new file mode 100644 index 000000000000..cd1e151477a6 --- /dev/null +++ b/packages/model-api-builder/package-lock.json @@ -0,0 +1,14 @@ +{ + "name": "@loopback/model-api-builder", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/node": { + "version": "10.14.16", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.16.tgz", + "integrity": "sha512-/opXIbfn0P+VLt+N8DE4l8Mn8rbhiJgabU96ZJ0p9mxOkIks5gh6RUnpHak7Yh0SFkyjO/ODbxsQQPV2bpMmyA==", + "dev": true + } + } +} diff --git a/packages/model-api-builder/package.json b/packages/model-api-builder/package.json new file mode 100644 index 000000000000..de408f6a0a16 --- /dev/null +++ b/packages/model-api-builder/package.json @@ -0,0 +1,45 @@ +{ + "name": "@loopback/model-api-builder", + "version": "0.0.1", + "description": "Types and helpers for packages contributing Model API builders.", + "engines": { + "node": ">=8.9" + }, + "main": "index", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "lb-tsc", + "clean": "lb-clean loopback-rest-builder-plugin*.tgz dist tsconfig.build.tsbuildinfo package", + "pretest": "npm run build", + "test": "", + "verify": "npm pack && tar xf loopback-rest-builder-plugin*.tgz && tree package && npm run clean" + }, + "author": "IBM Corp.", + "copyright.owner": "IBM Corp.", + "license": "MIT", + "peerDependencies": { + "@loopback/core": "^1.9.3", + "@loopback/repository": "^1.12.0" + }, + "devDependencies": { + "@loopback/build": "^1.7.1", + "@loopback/core": "^1.9.3", + "@loopback/repository": "^1.12.0", + "@types/node": "^10.14.15" + }, + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist", + "src", + "!*/__tests__" + ], + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git", + "directory": "packages/rest-builder-plugin" + } +} diff --git a/packages/model-api-builder/src/index.ts b/packages/model-api-builder/src/index.ts new file mode 100644 index 000000000000..384842b00a6c --- /dev/null +++ b/packages/model-api-builder/src/index.ts @@ -0,0 +1,7 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/model-api-builder +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './model-api-builder'; +export * from './model-api-config'; diff --git a/packages/model-api-builder/src/model-api-builder.ts b/packages/model-api-builder/src/model-api-builder.ts new file mode 100644 index 000000000000..8de7816f009e --- /dev/null +++ b/packages/model-api-builder/src/model-api-builder.ts @@ -0,0 +1,33 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/model-api-builder +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {BindingTemplate, extensionFor} from '@loopback/core'; +import {ApplicationWithRepositories, Model} from '@loopback/repository'; +import {ModelApiConfig} from './model-api-config'; + +/** + * Extension Point name for Model API builders. + */ +export const MODEL_API_BUILDER_PLUGINS = 'model-api-builders'; + +/** + * Interface for extensions contributing custom API flavors. + */ +export interface ModelApiBuilder { + readonly pattern: string; // e.g. CrudRest + setup( + application: ApplicationWithRepositories, + modelClass: typeof Model & {prototype: Model}, + config: ModelApiConfig, + ): Promise; +} + +/** + * A binding template for greeter extensions + */ +export const asModelApiBuilder: BindingTemplate = binding => { + extensionFor(MODEL_API_BUILDER_PLUGINS)(binding); + binding.tag({namespace: 'model-api-builders'}); +}; diff --git a/packages/model-api-builder/src/model-api-config.ts b/packages/model-api-builder/src/model-api-config.ts new file mode 100644 index 000000000000..f24c8b3b39f9 --- /dev/null +++ b/packages/model-api-builder/src/model-api-config.ts @@ -0,0 +1,21 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/model-api-builder +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/** + * Configuration settings for individual model files. This type describes + * content of `public-models/{model-name}.config.json` files. + */ +export type ModelApiConfig = { + // E.g. 'Product' + model: string; + // E.g. 'RestCrud' + pattern: string; + // E.g. 'db' + dataSource: string; + // E.g. '/products' + basePath: string; + + [patternSpecificSetting: string]: unknown; +}; diff --git a/packages/model-api-builder/tsconfig.build.json b/packages/model-api-builder/tsconfig.build.json new file mode 100644 index 000000000000..c7b8e49eaca5 --- /dev/null +++ b/packages/model-api-builder/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@loopback/build/config/tsconfig.common.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/repository/src/mixins/repository.mixin.ts b/packages/repository/src/mixins/repository.mixin.ts index e69fef960725..41acc6b1d8bc 100644 --- a/packages/repository/src/mixins/repository.mixin.ts +++ b/packages/repository/src/mixins/repository.mixin.ts @@ -8,6 +8,7 @@ import {Application} from '@loopback/core'; import * as debugFactory from 'debug'; import {Class} from '../common-types'; import {SchemaMigrationOptions} from '../datasource'; +import {Model} from '../model'; import {juggler, Repository} from '../repositories'; const debug = debugFactory('loopback:repository:mixin'); @@ -88,6 +89,22 @@ export function RepositoryMixin>(superClass: T) { return this.get(`repositories.${repo.name}`); } + /** + * Register a model for dependency injection. + * @param modelClass The model or entity to add, e.g. `Product`. + * @param name Optional name to use for building the binding key, + * e.g. `BaseProduct`. + */ + model( + modelClass: Class, + name: string = modelClass.name, + ) { + const key = `models.${name}`; + return this.bind(key) + .to(modelClass) + .tag('model'); + } + /** * Add the dataSource to this application. * @@ -231,6 +248,10 @@ export interface ApplicationWithRepositories extends Application { ): Binding; // eslint-disable-next-line @typescript-eslint/no-explicit-any getRepository>(repo: Class): Promise; + model( + modelClass: Class, + name?: string, + ): Binding>; dataSource( dataSource: Class | D, name?: string, diff --git a/packages/rest-crud/package-lock.json b/packages/rest-crud/package-lock.json index 087af0562727..76885185993e 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.14.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.17.tgz", "integrity": "sha512-p/sGgiPaathCfOtqu2fx5Mu1bcjuP8ALFg4xpGgNkcin7LwRyzUKniEHBKdcE1RPsenq5JVPIpMTJSygLboygQ==", "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 ef2506a9e81d..d0f57174f743 100644 --- a/packages/rest-crud/package.json +++ b/packages/rest-crud/package.json @@ -19,14 +19,21 @@ "author": "IBM Corp.", "copyright.owner": "IBM Corp.", "license": "MIT", + "dependencies": { + "@loopback/model-api-builder": "^0.0.1", + "debug": "^4.1.1" + }, "devDependencies": { "@loopback/build": "^1.7.1", + "@loopback/core": "^1.10.1", "@loopback/repository": "^1.13.1", "@loopback/rest": "^1.18.1", "@loopback/testlab": "^1.8.0", + "@types/debug": "^4.1.5", "@types/node": "^10.14.17" }, "peerDependencies": { + "@loopback/core": "^1.9.3", "@loopback/repository": "^1.12.0", "@loopback/rest": "^1.17.0" }, diff --git a/packages/rest-crud/src/crud-rest-builder.plugin.ts b/packages/rest-crud/src/crud-rest-builder.plugin.ts new file mode 100644 index 000000000000..4324b3fba288 --- /dev/null +++ b/packages/rest-crud/src/crud-rest-builder.plugin.ts @@ -0,0 +1,88 @@ +// Copyright IBM Corp. 2019. 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, ControllerClass, inject} from '@loopback/core'; +import { + asModelApiBuilder, + ModelApiBuilder, + ModelApiConfig, +} from '@loopback/model-api-builder'; +import { + ApplicationWithRepositories, + Class, + Entity, + EntityCrudRepository, + Model, +} from '@loopback/repository'; +import * as debugFactory from 'debug'; +import {defineCrudRestController} from './crud-rest.controller'; +import {defineRepositoryClass} from './repository-builder'; + +const debug = debugFactory('loopback:boot:crud-rest'); + +@bind(asModelApiBuilder) +export class CrudRestApiBuilder implements ModelApiBuilder { + readonly pattern: string = 'CrudRest'; + + setup( + application: ApplicationWithRepositories, + modelClass: typeof Model & {prototype: Model}, + config: ModelApiConfig, + ): Promise { + if (!(modelClass.prototype instanceof Entity)) { + throw new Error( + `CrudRestController requires an Entity, Models are not supported. (Model name: ${modelClass.name})`, + ); + } + const entityClass = modelClass as typeof Entity & {prototype: Entity}; + + // TODO Check if the repository class has been already defined. + // If yes, then skip creation of the default repository + const repositoryClass = setupCrudRepository(entityClass, config); + application.repository(repositoryClass); + debug('Registered repository class', repositoryClass.name); + + const controllerClass = setupCrudRestController(entityClass, config); + application.controller(controllerClass); + debug('Registered controller class', controllerClass.name); + + return Promise.resolve(); + } +} + +function setupCrudRepository( + entityClass: typeof Entity & {prototype: Entity}, + modelConfig: ModelApiConfig, +): Class> { + const repositoryClass = defineRepositoryClass(entityClass); + + inject(`datasources.${modelConfig.dataSource}`)( + repositoryClass, + undefined, + 0, + ); + + return repositoryClass; +} + +function setupCrudRestController( + entityClass: typeof Entity & {prototype: Entity}, + modelConfig: ModelApiConfig, +): ControllerClass { + const controllerClass = defineCrudRestController( + entityClass, + // important - forward the entire config object to allow controller + // factories to accept additional (custom) config options + modelConfig, + ); + + inject(`repositories.${entityClass.name}Repository`)( + controllerClass, + undefined, + 0, + ); + + return controllerClass; +} 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..f4efc545d0e7 --- /dev/null +++ b/packages/rest-crud/src/crud-rest.component.ts @@ -0,0 +1,11 @@ +// Copyright IBM Corp. 2019. 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-builder.plugin'; + +export class CrudRestComponent implements Component { + bindings = [createBindingFromClass(CrudRestApiBuilder)]; +} diff --git a/packages/rest-crud/src/crud-rest.controller.ts b/packages/rest-crud/src/crud-rest.controller.ts index 4ad0dd34105c..72cd6efc3233 100644 --- a/packages/rest-crud/src/crud-rest.controller.ts +++ b/packages/rest-crud/src/crud-rest.controller.ts @@ -32,6 +32,7 @@ import { ResponsesObject, SchemaObject, } from '@loopback/rest'; +import assert = require('assert'); // Ideally, this file should simply `export class CrudRestController<...>{}` // Unfortunately, that's not possible for several reasons. @@ -75,6 +76,8 @@ export interface CrudRestController< * @param data Model data */ create(data: Omit): Promise; + + // TODO(bajtos) define other methods like `deleteById`, etc. } /** @@ -254,8 +257,14 @@ export function defineCrudRestController< } } - // See https://github.com/microsoft/TypeScript/issues/14607 - return CrudRestControllerImpl; + const controllerName = modelName + 'Controller'; + const defineNamedController = new Function( + 'CrudRestController', + `return class ${controllerName} extends CrudRestController {}`, + ); + const controller = defineNamedController(CrudRestControllerImpl); + assert.equal(controller.name, controllerName); + return controller; } function getIdSchema( diff --git a/packages/rest-crud/src/index.ts b/packages/rest-crud/src/index.ts index a099ac3a456c..b419d371bfb4 100644 --- a/packages/rest-crud/src/index.ts +++ b/packages/rest-crud/src/index.ts @@ -3,4 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +export * from './crud-rest.component'; export * from './crud-rest.controller'; +export * from './repository-builder'; diff --git a/packages/rest-crud/src/repository-builder.ts b/packages/rest-crud/src/repository-builder.ts new file mode 100644 index 000000000000..12d3dece6631 --- /dev/null +++ b/packages/rest-crud/src/repository-builder.ts @@ -0,0 +1,51 @@ +// Copyright IBM Corp. 2019. 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 { + DefaultCrudRepository, + Entity, + EntityCrudRepository, + juggler, +} from '@loopback/repository'; +import * as assert from 'assert'; + +export function defineRepositoryClass< + T extends Entity, + IdType, + Relations extends object = {} +>( + entityClass: typeof Entity & {prototype: T}, +): RepositoryClass { + const repoName = entityClass.name + 'Repository'; + const defineNamedRepo = new Function( + 'EntityCtor', + 'BaseRepository', + `return class ${repoName} extends BaseRepository { + constructor(dataSource) { + super(EntityCtor, dataSource); + } + };`, + ); + + // TODO(bajtos) make DefaultCrudRepository configurable (?) + const repo = defineNamedRepo(entityClass, DefaultCrudRepository); + assert.equal(repo.name, repoName); + return repo; +} + +export interface RepositoryClass< + T extends Entity, + IdType, + Relations extends object +> { + new (ds: juggler.DataSource): EntityCrudRepository; +} + +interface CrudRepoCtor { + new ( + entityCtor: typeof Entity & {prototype: T}, + ds: juggler.DataSource, + ): EntityCrudRepository; +} From a76597489d5a0631a4202db64d94b12b19f00547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 6 Sep 2019 15:37:01 +0200 Subject: [PATCH 02/13] refactor: load model configs from JS files in `dist/public-models` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- _SPIKE_.md | 108 ++++++++++++------ examples/todo/public-models/todo.config.json | 6 - .../todo/src/public-models/todo.config.ts | 13 +++ .../boot/src/booters/base-artifact.booter.ts | 6 +- packages/boot/src/types.ts | 7 -- .../acceptance/rest-booter.acceptance.ts | 14 ++- packages/booter-rest/src/rest.booter.ts | 10 +- packages/rest-crud/src/index.ts | 2 + packages/testlab/src/test-sandbox.ts | 8 ++ 9 files changed, 109 insertions(+), 65 deletions(-) delete mode 100644 examples/todo/public-models/todo.config.json create mode 100644 examples/todo/src/public-models/todo.config.ts diff --git a/_SPIKE_.md b/_SPIKE_.md index 221c5358bb3f..af1bf1c42ae5 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -19,6 +19,27 @@ In this Spike, I am demonstrating a PoC implementation of an extensible booter that processed model configuration files in JSON formats and uses 3rd-party plugins to build repository & controller classes at runtime. +## Basic use + +Create `src/public-models` directory in your project. For each model you want to +expose via REST API, add a new `.ts` file that's exporting the model +configuration. + +Example: + +```ts +import {ModelApiConfig} from '@loopback/rest-crud'; + +module.exports = { + model: 'Product', + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/products', +}; +``` + +## Implementation + The solution has the following high-level parts: 1. A new package `@loopback/model-api-builder` defines the contract for plugins @@ -58,7 +79,7 @@ In my proposal, model-config files are in JSON format to make programmatic edits easier. This has a downside in TypeScript projects - these config files must live outside `src` because TypeScript does not copy arbitrary JSON files. -### Extensibility & customization options +## Extensibility & customization options The proposed design enables the following opportunities to extend and customize the default behavior of API endpoints: @@ -97,38 +118,54 @@ The CRUD REST API builder: The remaining changes are small tweaks & improvements of existing packages to better support this spike. -## Open questions: +## ~~Open~~ questions answered: + +**Q: Where to keep model config files?** + +- `/public-models/product.config.json` (JSON, must be outside src) +- `/src/public-models/product-config.ts` (TS, can be inside src, more flexible) + +**Answer:** -- Where to keep model config files? +Let's keep them as TS files in `src/public-models`. I feel this is more +consistent with the approach we use for all other artifacts (models, +repositories, etc.). It also enables application developers to conditionally +customize model config, e.g. depending on `process.env` variables. - - `/public-models/product.config.json` (JSON, must be outside src) - - `/src/public-models/product-config.ts` (TS, can be inside src, more - flexible) +**Q: Load models via DI, or rather let config files to load them via require?** -- Load models via DI, or rather let config files to load them via require? +```ts +// in src/public-models/product-config.ts +{ + model: require('../models/product.model').Product, + // ... +} +``` - ```ts - // in src/public-models/product-config.ts - { - model: require('../models/product.model').Product, - // ... - } - ``` +**Answer** -- If we use TS files, then we can get rid of the extension point too +Load models via DI for consistency. We can add support for loading models via +`require` later, based on user demand. The change will be backwards-compatible. - ```ts - // in src/public-models/product-config.ts - { - model: require('../models/product.model').Product, - pattern: require('@loopback/rest-crud').CrudRestApiBuilder, - basePath: '/products', - dataSource: 'db', +**Q: If we use TS files, then we can get rid of the extension point too** - // alternatively: - dataSource: require('../datasources/db.datasource').DbDataSource, - } - ``` +```ts +// in src/public-models/product-config.ts +{ + model: require('../models/product.model').Product, + pattern: require('@loopback/rest-crud').CrudRestApiBuilder, + basePath: '/products', + dataSource: 'db', + + // alternatively: + dataSource: require('../datasources/db.datasource').DbDataSource, +} +``` + +**Answer:** + +Same as for models. Use DI in the initial implementation. Add support for +`require`-based approach later, based on user demand. ## Tasks @@ -136,22 +173,21 @@ TBD, this is a preliminary & incomplete list. - Add `app.model(Model, name)` API to RepositoryMixin. - - Do we want to introduce `@model()` decorator for configuring dependency + - Q: Do we want to introduce `@model()` decorator for configuring dependency injection? (Similar to `@repository`.) - - Do we want to rework scaffolded repositories to receive the model class via - DI? -- Implement model booter to scan `dist/models/**/*.model.js` files and register - them by calling `app.model`. + A: No, that would clash with `@model` exported by `@loopback/repository`. -- Implement `sandbox.writeJsonFile` in `@loopback/testlab`. + - Q: Do we want to rework scaffolded repositories to receive the model class + via DI? -- Add support for artifact option `rootDir` to `@loopback/boot`. + A: I feel that's preliminary at this point. Let's wait until we have a + (real-world) use case for that. -- Improve rest-crud to create a named controller class. +- Implement model booter to scan `dist/models/**/*.model.js` files and register + them by calling `app.model`. -- Improve `@loopback/metadata` and `@loopback/context` per changes made in this - spike +- Improve rest-crud to create a named controller class. TBD: stories for the actual implementation diff --git a/examples/todo/public-models/todo.config.json b/examples/todo/public-models/todo.config.json deleted file mode 100644 index 7271f3f4efc1..000000000000 --- a/examples/todo/public-models/todo.config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "model": "Todo", - "pattern": "CrudRest", - "dataSource": "db", - "basePath": "/todos" -} diff --git a/examples/todo/src/public-models/todo.config.ts b/examples/todo/src/public-models/todo.config.ts new file mode 100644 index 000000000000..54939a404084 --- /dev/null +++ b/examples/todo/src/public-models/todo.config.ts @@ -0,0 +1,13 @@ +// Copyright IBM Corp. 2018,2019. All Rights Reserved. +// Node module: @loopback/example-todo +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {ModelApiConfig} from '@loopback/rest-crud'; + +module.exports = { + model: 'Todo', + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/todos', +}; diff --git a/packages/boot/src/booters/base-artifact.booter.ts b/packages/boot/src/booters/base-artifact.booter.ts index 986ca7cffc2d..3709497f4d90 100644 --- a/packages/boot/src/booters/base-artifact.booter.ts +++ b/packages/boot/src/booters/base-artifact.booter.ts @@ -36,7 +36,7 @@ export class BaseArtifactBooter implements Booter { /** * Project root relative to which all other paths are resolved */ - projectRoot: string; + readonly projectRoot: string; /** * Relative paths of directories to be searched */ @@ -79,10 +79,6 @@ export class BaseArtifactBooter implements Booter { * NOTE: All properties are configured even if all aren't used. */ async configure() { - if (this.options.rootDir) { - this.projectRoot = path.resolve(this.projectRoot, this.options.rootDir); - } - this.dirs = this.options.dirs ? Array.isArray(this.options.dirs) ? this.options.dirs diff --git a/packages/boot/src/types.ts b/packages/boot/src/types.ts index 26b94a2e8491..64c0566a0be4 100644 --- a/packages/boot/src/types.ts +++ b/packages/boot/src/types.ts @@ -11,13 +11,6 @@ import {BootTags} from './keys'; * this Booter. */ export type ArtifactOptions = { - /** - * Modify the rootDir provided by the application. Useful to escape `dist` - * directory and load artifacts from the project root. - * Example value: `../`; - */ - rootDir?: string; - /** * Array of directories to check for artifacts. * Paths must be relative. Defaults to ['controllers'] diff --git a/packages/booter-rest/src/__tests__/acceptance/rest-booter.acceptance.ts b/packages/booter-rest/src/__tests__/acceptance/rest-booter.acceptance.ts index 81169773a570..3fdcbff51656 100644 --- a/packages/booter-rest/src/__tests__/acceptance/rest-booter.acceptance.ts +++ b/packages/booter-rest/src/__tests__/acceptance/rest-booter.acceptance.ts @@ -5,6 +5,7 @@ import {BootMixin} from '@loopback/boot'; import {ApplicationConfig} from '@loopback/core'; +import {ModelApiConfig} from '@loopback/model-api-builder'; import { DefaultCrudRepository, Entity, @@ -49,18 +50,23 @@ describe('rest booter acceptance tests', () => { app.model(Product); // Write model-config file to specify how to expose the model via API - await sandbox.writeJsonFile('public-models/product.config.json', { + const cfg: ModelApiConfig = { model: 'Product', pattern: 'CrudRest', dataSource: 'db', basePath: '/products', - }); + }; + await sandbox.writeTextFile( + 'dist/public-models/product.config.js', + `module.exports = ${JSON.stringify(cfg, null, 2)}`, + ); + // Boot & start the application await app.boot(); await app.start(); - const client = createRestAppClient(app); + // Verify that we have REST API for our model const created = await client .post('/products') .send({name: 'a name'}) @@ -68,7 +74,7 @@ describe('rest booter acceptance tests', () => { const found = await client.get('/products').expect(200); expect(found.body).to.deepEqual([{id: created.body.id, name: 'a name'}]); - // verify that we have a repository class to use e.g. in tests + // Verify that we have a repository class to use e.g. in tests const repo = await app.get< DefaultCrudRepository >('repositories.ProductRepository'); diff --git a/packages/booter-rest/src/rest.booter.ts b/packages/booter-rest/src/rest.booter.ts index e691d818d35c..6cfca31d2739 100644 --- a/packages/booter-rest/src/rest.booter.ts +++ b/packages/booter-rest/src/rest.booter.ts @@ -19,16 +19,14 @@ import { } from '@loopback/core'; import { ModelApiBuilder, + ModelApiConfig, MODEL_API_BUILDER_PLUGINS, } from '@loopback/model-api-builder'; import {ApplicationWithRepositories, Model} from '@loopback/repository'; import * as debugFactory from 'debug'; -import * as fs from 'fs'; import * as path from 'path'; -import {promisify} from 'util'; const debug = debugFactory('loopback:boot:rest-booter'); -const readFile = promisify(fs.readFile); @booter('rest') @extensionPoint(MODEL_API_BUILDER_PLUGINS) @@ -70,7 +68,7 @@ export class RestBooter extends BaseArtifactBooter { } async setupModel(configFile: string): Promise { - const cfg = JSON.parse(await readFile(configFile, {encoding: 'utf-8'})); + const cfg: ModelApiConfig = require(configFile); debug( 'Loaded model config from %s', path.relative(this.projectRoot, configFile), @@ -103,9 +101,7 @@ export class RestBooter extends BaseArtifactBooter { * Default ArtifactOptions for ControllerBooter. */ export const RestDefaults: ArtifactOptions = { - // public-models should live outside of "dist" - rootDir: '../', dirs: ['public-models'], - extensions: ['.config.json'], + extensions: ['.config.js'], nested: true, }; diff --git a/packages/rest-crud/src/index.ts b/packages/rest-crud/src/index.ts index b419d371bfb4..a01196cb4f32 100644 --- a/packages/rest-crud/src/index.ts +++ b/packages/rest-crud/src/index.ts @@ -3,6 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +// Re-export ModelApiConfig for use in application config files +export {ModelApiConfig} from '@loopback/model-api-builder'; export * from './crud-rest.component'; export * from './crud-rest.controller'; export * from './repository-builder'; diff --git a/packages/testlab/src/test-sandbox.ts b/packages/testlab/src/test-sandbox.ts index da1f22d0471b..3cf3465c7eb2 100644 --- a/packages/testlab/src/test-sandbox.ts +++ b/packages/testlab/src/test-sandbox.ts @@ -11,6 +11,7 @@ import { ensureDirSync, pathExists, remove, + writeFile, writeJson, } from 'fs-extra'; import {parse, resolve} from 'path'; @@ -119,4 +120,11 @@ export class TestSandbox { await ensureDir(destDir); return writeJson(dest, data, {spaces: 2}); } + + async writeTextFile(dest: string, data: string): Promise { + dest = resolve(this.path, dest); + const destDir = parse(dest).dir; + await ensureDir(destDir); + return writeFile(dest, data, {encoding: 'utf-8'}); + } } From 83cf75bc2a7a0cd31dae659dc8a60f9a5324abc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 6 Sep 2019 16:09:49 +0200 Subject: [PATCH 03/13] refactor: move RestBooter to `@loopback/boot` as ModelApiBooter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- examples/todo/src/application.ts | 2 - packages/boot/package.json | 2 + .../model-api.booter.acceptance.ts} | 7 +-- packages/boot/src/boot.component.ts | 2 + packages/boot/src/booters/index.ts | 1 + .../src/booters/model-api.booter.ts} | 17 +++--- packages/booter-rest/.npmrc | 1 - packages/booter-rest/LICENSE | 25 --------- packages/booter-rest/README.md | 36 ------------ packages/booter-rest/index.d.ts | 6 -- packages/booter-rest/index.js | 6 -- packages/booter-rest/index.ts | 8 --- packages/booter-rest/package-lock.json | 33 ----------- packages/booter-rest/package.json | 55 ------------------- packages/booter-rest/src/index.ts | 6 -- packages/booter-rest/tsconfig.build.json | 9 --- 16 files changed, 15 insertions(+), 201 deletions(-) rename packages/{booter-rest/src/__tests__/acceptance/rest-booter.acceptance.ts => boot/src/__tests__/acceptance/model-api.booter.acceptance.ts} (95%) rename packages/{booter-rest/src/rest.booter.ts => boot/src/booters/model-api.booter.ts} (90%) delete mode 100644 packages/booter-rest/.npmrc delete mode 100644 packages/booter-rest/LICENSE delete mode 100644 packages/booter-rest/README.md delete mode 100644 packages/booter-rest/index.d.ts delete mode 100644 packages/booter-rest/index.js delete mode 100644 packages/booter-rest/index.ts delete mode 100644 packages/booter-rest/package-lock.json delete mode 100644 packages/booter-rest/package.json delete mode 100644 packages/booter-rest/src/index.ts delete mode 100644 packages/booter-rest/tsconfig.build.json diff --git a/examples/todo/src/application.ts b/examples/todo/src/application.ts index 9cbbc0afbf20..8f0805122de1 100644 --- a/examples/todo/src/application.ts +++ b/examples/todo/src/application.ts @@ -4,7 +4,6 @@ // License text available at https://opensource.org/licenses/MIT import {BootMixin} from '@loopback/boot'; -import {RestBooter} from '@loopback/booter-rest'; import {ApplicationConfig} from '@loopback/core'; import {RepositoryMixin} from '@loopback/repository'; import {RestApplication} from '@loopback/rest'; @@ -29,7 +28,6 @@ export class TodoListApplication extends BootMixin( this.component(RestExplorerComponent); this.component(CrudRestComponent); - this.booters(RestBooter); this.projectRoot = __dirname; // Customize @loopback/boot Booter Conventions here diff --git a/packages/boot/package.json b/packages/boot/package.json index 54e3372cba20..5cafc3c0c4a1 100644 --- a/packages/boot/package.json +++ b/packages/boot/package.json @@ -24,6 +24,7 @@ "dependencies": { "@loopback/context": "^1.22.1", "@loopback/core": "^1.10.1", + "@loopback/model-api-builder": "^0.0.1", "@loopback/repository": "^1.13.1", "@loopback/service-proxy": "^1.3.5", "@types/debug": "^4.1.5", @@ -36,6 +37,7 @@ "@loopback/eslint-config": "^4.0.2", "@loopback/openapi-v3": "^1.9.6", "@loopback/rest": "^1.18.1", + "@loopback/rest-crud": "^0.0.1", "@loopback/testlab": "^1.8.0", "@types/node": "^10.14.17" }, diff --git a/packages/booter-rest/src/__tests__/acceptance/rest-booter.acceptance.ts b/packages/boot/src/__tests__/acceptance/model-api.booter.acceptance.ts similarity index 95% rename from packages/booter-rest/src/__tests__/acceptance/rest-booter.acceptance.ts rename to packages/boot/src/__tests__/acceptance/model-api.booter.acceptance.ts index 3fdcbff51656..33bf76b5daac 100644 --- a/packages/booter-rest/src/__tests__/acceptance/rest-booter.acceptance.ts +++ b/packages/boot/src/__tests__/acceptance/model-api.booter.acceptance.ts @@ -1,9 +1,8 @@ // Copyright IBM Corp. 2019. All Rights Reserved. -// Node module: @loopback/booter-rest +// 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 {ModelApiConfig} from '@loopback/model-api-builder'; import { @@ -24,7 +23,7 @@ import { toJSON, } from '@loopback/testlab'; import {resolve} from 'path'; -import {RestBooter} from '../../rest.booter'; +import {BootMixin, ModelApiBooter} from '../..'; describe('rest booter acceptance tests', () => { let app: BooterApp; @@ -86,7 +85,7 @@ describe('rest booter acceptance tests', () => { constructor(options?: ApplicationConfig) { super(options); this.projectRoot = sandbox.path + '/dist'; - this.booters(RestBooter); + this.booters(ModelApiBooter); this.component(CrudRestComponent); } } diff --git a/packages/boot/src/boot.component.ts b/packages/boot/src/boot.component.ts index 4da843bada63..d355df30b264 100644 --- a/packages/boot/src/boot.component.ts +++ b/packages/boot/src/boot.component.ts @@ -11,6 +11,7 @@ import { DataSourceBooter, InterceptorProviderBooter, LifeCycleObserverBooter, + ModelApiBooter, RepositoryBooter, ServiceBooter, } from './booters'; @@ -33,6 +34,7 @@ export class BootComponent implements Component { DataSourceBooter, LifeCycleObserverBooter, InterceptorProviderBooter, + ModelApiBooter, ]; /** diff --git a/packages/boot/src/booters/index.ts b/packages/boot/src/booters/index.ts index 838406023316..6458d685a5cf 100644 --- a/packages/boot/src/booters/index.ts +++ b/packages/boot/src/booters/index.ts @@ -10,5 +10,6 @@ export * from './controller.booter'; export * from './datasource.booter'; export * from './interceptor.booter'; export * from './lifecyle-observer.booter'; +export * from './model-api.booter'; export * from './repository.booter'; export * from './service.booter'; diff --git a/packages/booter-rest/src/rest.booter.ts b/packages/boot/src/booters/model-api.booter.ts similarity index 90% rename from packages/booter-rest/src/rest.booter.ts rename to packages/boot/src/booters/model-api.booter.ts index 6cfca31d2739..38f79aa48036 100644 --- a/packages/booter-rest/src/rest.booter.ts +++ b/packages/boot/src/booters/model-api.booter.ts @@ -1,14 +1,8 @@ // Copyright IBM Corp. 2019. All Rights Reserved. -// Node module: @loopback/booter-rest +// Node module: @loopback/boot // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import { - ArtifactOptions, - BaseArtifactBooter, - BootBindings, - booter, -} from '@loopback/boot'; import { config, CoreBindings, @@ -25,12 +19,15 @@ import { import {ApplicationWithRepositories, Model} from '@loopback/repository'; import * as debugFactory from 'debug'; import * as path from 'path'; +import {BootBindings} from '../keys'; +import {ArtifactOptions, booter} from '../types'; +import {BaseArtifactBooter} from './base-artifact.booter'; -const debug = debugFactory('loopback:boot:rest-booter'); +const debug = debugFactory('loopback:boot:model-api'); -@booter('rest') +@booter('modelApi') @extensionPoint(MODEL_API_BUILDER_PLUGINS) -export class RestBooter extends BaseArtifactBooter { +export class ModelApiBooter extends BaseArtifactBooter { constructor( @inject(CoreBindings.APPLICATION_INSTANCE) public app: ApplicationWithRepositories, diff --git a/packages/booter-rest/.npmrc b/packages/booter-rest/.npmrc deleted file mode 100644 index cafe685a112d..000000000000 --- a/packages/booter-rest/.npmrc +++ /dev/null @@ -1 +0,0 @@ -package-lock=true diff --git a/packages/booter-rest/LICENSE b/packages/booter-rest/LICENSE deleted file mode 100644 index 6d24a116f50b..000000000000 --- a/packages/booter-rest/LICENSE +++ /dev/null @@ -1,25 +0,0 @@ -Copyright (c) IBM Corp. 2019. All Rights Reserved. -Node module: @loopback/booter-rest -This project is licensed under the MIT License, full text below. - --------- - -MIT license - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/packages/booter-rest/README.md b/packages/booter-rest/README.md deleted file mode 100644 index 9cc15080256d..000000000000 --- a/packages/booter-rest/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# @loopback/booter-rest - -REST API controller implementing default CRUD semantics. - -## Overview - -This module allows applications to quickly expose a model via REST API without -having to implement a custom controller class. - -## Installation - -```sh -npm install --save @loopback/booter-rest -``` - -## Basic use - -TBD - -## Contributions - -- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md) -- [Join the team](https://github.com/strongloop/loopback-next/issues/110) - -## Tests - -Run `npm test` from the root folder. - -## Contributors - -See -[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). - -## License - -MIT diff --git a/packages/booter-rest/index.d.ts b/packages/booter-rest/index.d.ts deleted file mode 100644 index e40f802c2eae..000000000000 --- a/packages/booter-rest/index.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright IBM Corp. 2019. All Rights Reserved. -// Node module: @loopback/booter-rest -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -export * from './dist'; diff --git a/packages/booter-rest/index.js b/packages/booter-rest/index.js deleted file mode 100644 index 0bed8e5b21a7..000000000000 --- a/packages/booter-rest/index.js +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright IBM Corp. 2019. All Rights Reserved. -// Node module: @loopback/booter-rest -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -module.exports = require('./dist'); diff --git a/packages/booter-rest/index.ts b/packages/booter-rest/index.ts deleted file mode 100644 index f99bc4447f8d..000000000000 --- a/packages/booter-rest/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright IBM Corp. 2019. All Rights Reserved. -// Node module: @loopback/booter-rest -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -// DO NOT EDIT THIS FILE -// Add any additional (re)exports to src/index.ts instead. -export * from './src'; diff --git a/packages/booter-rest/package-lock.json b/packages/booter-rest/package-lock.json deleted file mode 100644 index 2333a2e75d68..000000000000 --- a/packages/booter-rest/package-lock.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@loopback/booter-rest", - "version": "0.0.1", - "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.14.16", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.16.tgz", - "integrity": "sha512-/opXIbfn0P+VLt+N8DE4l8Mn8rbhiJgabU96ZJ0p9mxOkIks5gh6RUnpHak7Yh0SFkyjO/ODbxsQQPV2bpMmyA==", - "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/booter-rest/package.json b/packages/booter-rest/package.json deleted file mode 100644 index 8794d8d478b9..000000000000 --- a/packages/booter-rest/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@loopback/booter-rest", - "version": "0.0.1", - "description": "REST API controller implementing default CRUD semantics", - "engines": { - "node": ">=8.9" - }, - "main": "index", - "publishConfig": { - "access": "public" - }, - "scripts": { - "build": "lb-tsc", - "clean": "lb-clean loopback-booter-rest*.tgz dist tsconfig.build.tsbuildinfo package", - "pretest": "npm run build", - "test": "lb-mocha \"dist/__tests__/**/*.js\"", - "verify": "npm pack && tar xf loopback-booter-rest*.tgz && tree package && npm run clean" - }, - "author": "IBM Corp.", - "copyright.owner": "IBM Corp.", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1" - }, - "peerDependencies": { - "@loopback/boot": "^1.5.3", - "@loopback/core": "^1.9.3", - "@loopback/rest": "^1.17.0" - }, - "devDependencies": { - "@loopback/boot": "^1.5.3", - "@loopback/model-api-builder": "^0.0.1", - "@loopback/build": "^1.7.1", - "@loopback/core": "^1.9.3", - "@loopback/repository": "^1.12.0", - "@loopback/rest": "^1.17.0", - "@loopback/rest-crud": "^0.0.1", - "@loopback/testlab": "^1.7.4", - "@types/debug": "^4.1.5", - "@types/node": "^10.14.15" - }, - "files": [ - "README.md", - "index.js", - "index.d.ts", - "dist", - "src", - "!*/__tests__" - ], - "repository": { - "type": "git", - "url": "https://github.com/strongloop/loopback-next.git", - "directory": "packages/booter-rest" - } -} diff --git a/packages/booter-rest/src/index.ts b/packages/booter-rest/src/index.ts deleted file mode 100644 index 68d0ad395540..000000000000 --- a/packages/booter-rest/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright IBM Corp. 2019. All Rights Reserved. -// Node module: @loopback/booter-rest -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -export * from './rest.booter'; diff --git a/packages/booter-rest/tsconfig.build.json b/packages/booter-rest/tsconfig.build.json deleted file mode 100644 index c7b8e49eaca5..000000000000 --- a/packages/booter-rest/tsconfig.build.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/tsconfig", - "extends": "@loopback/build/config/tsconfig.common.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} From a970f0c6cdb68d4191c626fb73bc5b4afc293fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 6 Sep 2019 16:11:25 +0200 Subject: [PATCH 04/13] docs: more explanation for extensibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- _SPIKE_.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/_SPIKE_.md b/_SPIKE_.md index af1bf1c42ae5..62bf3dfcf534 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -94,6 +94,32 @@ the default behavior of API endpoints: additional model-config options to further tweak the behavior of API endpoints. +**Question:** + +Could you elaborate more about how to customize the controller functions? Like +applying interceptors and authentication/authorization? By creating plugins? + +**Answer:** + +First of all, if you want to customize controller functions, then your project +has most likely outgrown the simplicity offered by `@loopback/rest-crud` and +it's time to scaffold a controller class using `lb4 controller`. + +Having said that, if you want to apply the same customization to multiple +models/controllers, for example if you want to apply the same +authentication/authorization rules, then you can: + +1. Fork `@loopback/rest` into your own package, or perhaps just copy the few + relevant files into your project. +2. Modify the controller class as you like. +3. You can also change the way how repositories are created (if needed). +4. Modify the ModelApiBuilder copied from `@loobpack/rest` - give it a different + pattern name (not `CrudRest`). +5. Bind your modified `ModelApiBuilder` to your app, so that the booter can find + it. +6. In your model-config files, replace the `pattern` value from `CrudRest` to + the new builder name you choose in the step 4. + ## How to review the spike I have updated `examples/todo` application to leverage this new functionality, From 03b71f0a8ffa663483b1c284f5a6f299a4614e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 6 Sep 2019 16:18:20 +0200 Subject: [PATCH 05/13] fix: copyright headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- packages/model-api-builder/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/model-api-builder/index.js b/packages/model-api-builder/index.js index 3f6822487e92..714c64ef02d8 100644 --- a/packages/model-api-builder/index.js +++ b/packages/model-api-builder/index.js @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2019. All Rights Reserved. -// Node module: @loopback/rest-builder-plugin +// Node module: @loopback/model-api-builder // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT From 785d4bdf8ef95b9d997cac72cae2e17c21f21640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 6 Sep 2019 16:27:01 +0200 Subject: [PATCH 06/13] docs: finalize the spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- _SPIKE_.md | 49 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/_SPIKE_.md b/_SPIKE_.md index 62bf3dfcf534..df270c88c9c0 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -22,10 +22,10 @@ plugins to build repository & controller classes at runtime. ## Basic use Create `src/public-models` directory in your project. For each model you want to -expose via REST API, add a new `.ts` file that's exporting the model +expose via REST API, add a new `.config.ts` file that's exporting the model configuration. -Example: +Example (`src/public-models/product.config.ts`): ```ts import {ModelApiConfig} from '@loopback/rest-crud'; @@ -195,27 +195,46 @@ Same as for models. Use DI in the initial implementation. Add support for ## Tasks -TBD, this is a preliminary & incomplete list. +1. Add `app.model(Model, name)` API to RepositoryMixin. -- Add `app.model(Model, name)` API to RepositoryMixin. +- Q: Do we want to introduce `@model()` decorator for configuring dependency + injection? (Similar to `@repository`.) - - Q: Do we want to introduce `@model()` decorator for configuring dependency - injection? (Similar to `@repository`.) + A: No, that would clash with `@model` exported by `@loopback/repository`. - A: No, that would clash with `@model` exported by `@loopback/repository`. +- Q: Do we want to rework scaffolded repositories to receive the model class via + DI? - - Q: Do we want to rework scaffolded repositories to receive the model class - via DI? + A: I feel that's preliminary at this point. Let's wait until we have a + (real-world) use case for that. - A: I feel that's preliminary at this point. Let's wait until we have a - (real-world) use case for that. +2. Implement model booter to scan `dist/models/**/*.model.js` files and register + them by calling `app.model`. -- Implement model booter to scan `dist/models/**/*.model.js` files and register - them by calling `app.model`. +3. Implement `sandbox.writeTextFile` helper, include test coverage. -- Improve rest-crud to create a named controller class. +4. Improve `@loopback/rest-crud` to create a named controller class (modify + `defineCrudRestController`) -TBD: stories for the actual implementation +5. Add `defineRepositoryClass` to `@loopback/rest-crud`, this function should + create a named repository class for the given Model class. + +6. Implement Model API booter & builder. + + - Add a new package `@loopback/model-api-builder`, copy the contents from + this spike. Improve README with basic documentation for users (extension + developers building custom Model API Builders). + + - Add `ModelApiBooter` to `@loopback/boot` + +7. Add `CrudRestApiBuilder` to `@loopback/rest-crud`. Modify `README`, rework + "Basic use" to show how to the package together with `ModelApiBooter` to go + from a model to REST API. Move the current content of "basic use" into a new + section, e.g. "Advanced use". + +8. Create a new example app based on the modified version of `examples/todo` + shown in the spike. The app should have a single `Todo` model and use + `ModelApiBooter` to expose the model via REST API. ### Out of scope From 2602fcc090a6420abc2ff854b5843897cbca4618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 9 Sep 2019 10:05:25 +0200 Subject: [PATCH 07/13] fixup! address review comments (refactor-rename) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- _SPIKE_.md | 2 +- packages/boot/src/booters/model-api.booter.ts | 2 +- packages/model-api-builder/src/model-api-builder.ts | 2 +- packages/rest-crud/src/crud-rest-builder.plugin.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/_SPIKE_.md b/_SPIKE_.md index df270c88c9c0..5878080535ac 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -45,7 +45,7 @@ The solution has the following high-level parts: 1. A new package `@loopback/model-api-builder` defines the contract for plugins (extensions) contributing repository & controller builders. -2. A new booter `RestBooter` that loads all JSON files from +2. A new booter `ModelApiBooter` that loads all JSON files from `/public-models/{model-name}.config.json`, resolves model name into a model class (via Application context), finds model api builder using Extension/ExtensionPoint pattern and delegates the remaining work to the diff --git a/packages/boot/src/booters/model-api.booter.ts b/packages/boot/src/booters/model-api.booter.ts index 38f79aa48036..e033a5177557 100644 --- a/packages/boot/src/booters/model-api.booter.ts +++ b/packages/boot/src/booters/model-api.booter.ts @@ -77,7 +77,7 @@ export class ModelApiBooter extends BaseArtifactBooter { ); const builder = await this.getApiBuilderForPattern(cfg.pattern); - await builder.setup(this.app, modelClass, cfg); + await builder.build(this.app, modelClass, cfg); } async getApiBuilderForPattern(pattern: string): Promise { diff --git a/packages/model-api-builder/src/model-api-builder.ts b/packages/model-api-builder/src/model-api-builder.ts index 8de7816f009e..dbcb6f7267f9 100644 --- a/packages/model-api-builder/src/model-api-builder.ts +++ b/packages/model-api-builder/src/model-api-builder.ts @@ -17,7 +17,7 @@ export const MODEL_API_BUILDER_PLUGINS = 'model-api-builders'; */ export interface ModelApiBuilder { readonly pattern: string; // e.g. CrudRest - setup( + build( application: ApplicationWithRepositories, modelClass: typeof Model & {prototype: Model}, config: ModelApiConfig, diff --git a/packages/rest-crud/src/crud-rest-builder.plugin.ts b/packages/rest-crud/src/crud-rest-builder.plugin.ts index 4324b3fba288..00719c039268 100644 --- a/packages/rest-crud/src/crud-rest-builder.plugin.ts +++ b/packages/rest-crud/src/crud-rest-builder.plugin.ts @@ -26,7 +26,7 @@ const debug = debugFactory('loopback:boot:crud-rest'); export class CrudRestApiBuilder implements ModelApiBuilder { readonly pattern: string = 'CrudRest'; - setup( + build( application: ApplicationWithRepositories, modelClass: typeof Model & {prototype: Model}, config: ModelApiConfig, From e10bd5edef254771a576a3a3c02260d3a5b295fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 9 Sep 2019 10:44:49 +0200 Subject: [PATCH 08/13] refactor: move basePath to rest-crud config iface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- _SPIKE_.md | 4 ++-- .../todo/src/public-models/todo.config.ts | 4 ++-- .../model-api-builder/src/model-api-config.ts | 2 -- .../rest-crud/src/crud-rest-builder.plugin.ts | 21 +++++++++++++++---- packages/rest-crud/src/index.ts | 3 +-- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/_SPIKE_.md b/_SPIKE_.md index 5878080535ac..f1ec9c87bdc8 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -28,9 +28,9 @@ configuration. Example (`src/public-models/product.config.ts`): ```ts -import {ModelApiConfig} from '@loopback/rest-crud'; +import {CrudRestApiConfig} from '@loopback/rest-crud'; -module.exports = { +module.exports = { model: 'Product', pattern: 'CrudRest', dataSource: 'db', diff --git a/examples/todo/src/public-models/todo.config.ts b/examples/todo/src/public-models/todo.config.ts index 54939a404084..a20647181ac5 100644 --- a/examples/todo/src/public-models/todo.config.ts +++ b/examples/todo/src/public-models/todo.config.ts @@ -3,9 +3,9 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {ModelApiConfig} from '@loopback/rest-crud'; +import {CrudRestApiConfig} from '@loopback/rest-crud'; -module.exports = { +module.exports = { model: 'Todo', pattern: 'CrudRest', dataSource: 'db', diff --git a/packages/model-api-builder/src/model-api-config.ts b/packages/model-api-builder/src/model-api-config.ts index f24c8b3b39f9..89bafe912acb 100644 --- a/packages/model-api-builder/src/model-api-config.ts +++ b/packages/model-api-builder/src/model-api-config.ts @@ -14,8 +14,6 @@ export type ModelApiConfig = { pattern: string; // E.g. 'db' dataSource: string; - // E.g. '/products' - basePath: string; [patternSpecificSetting: string]: unknown; }; diff --git a/packages/rest-crud/src/crud-rest-builder.plugin.ts b/packages/rest-crud/src/crud-rest-builder.plugin.ts index 00719c039268..3e6a2e9ecdda 100644 --- a/packages/rest-crud/src/crud-rest-builder.plugin.ts +++ b/packages/rest-crud/src/crud-rest-builder.plugin.ts @@ -22,6 +22,11 @@ import {defineRepositoryClass} from './repository-builder'; const debug = debugFactory('loopback:boot:crud-rest'); +export interface CrudRestApiConfig extends ModelApiConfig { + // E.g. '/products' + basePath: string; +} + @bind(asModelApiBuilder) export class CrudRestApiBuilder implements ModelApiBuilder { readonly pattern: string = 'CrudRest'; @@ -29,11 +34,19 @@ export class CrudRestApiBuilder implements ModelApiBuilder { build( application: ApplicationWithRepositories, modelClass: typeof Model & {prototype: Model}, - config: ModelApiConfig, + cfg: ModelApiConfig, ): Promise { + const modelName = modelClass.name; + const config = cfg as CrudRestApiConfig; + 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 an Entity, Models are not supported. (Model name: ${modelClass.name})`, + `CrudRestController requires an Entity, Models are not supported. (Model name: ${modelName})`, ); } const entityClass = modelClass as typeof Entity & {prototype: Entity}; @@ -54,7 +67,7 @@ export class CrudRestApiBuilder implements ModelApiBuilder { function setupCrudRepository( entityClass: typeof Entity & {prototype: Entity}, - modelConfig: ModelApiConfig, + modelConfig: CrudRestApiConfig, ): Class> { const repositoryClass = defineRepositoryClass(entityClass); @@ -69,7 +82,7 @@ function setupCrudRepository( function setupCrudRestController( entityClass: typeof Entity & {prototype: Entity}, - modelConfig: ModelApiConfig, + modelConfig: CrudRestApiConfig, ): ControllerClass { const controllerClass = defineCrudRestController( entityClass, diff --git a/packages/rest-crud/src/index.ts b/packages/rest-crud/src/index.ts index a01196cb4f32..3b2f1f78c4ab 100644 --- a/packages/rest-crud/src/index.ts +++ b/packages/rest-crud/src/index.ts @@ -3,8 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -// Re-export ModelApiConfig for use in application config files -export {ModelApiConfig} from '@loopback/model-api-builder'; +export {CrudRestApiConfig} from './crud-rest-builder.plugin'; export * from './crud-rest.component'; export * from './crud-rest.controller'; export * from './repository-builder'; From 446d5f50b6cffaccb68bd00e2b2273f42deabb2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 10 Sep 2019 10:51:46 +0200 Subject: [PATCH 09/13] chore: cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- _SPIKE_.md | 4 ++-- examples/todo/package.json | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/_SPIKE_.md b/_SPIKE_.md index f1ec9c87bdc8..d9e65df33083 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -128,7 +128,7 @@ like. Take a look at how acceptance level tests are accessing the repository created by `rest-crud` plugin. The booter is implemented in -[`packages/booter-rest/src/rest.booter.ts`](https://github.com/strongloop/loopback-next/blob/spike/crud-rest-booter/packages/booter-rest/src/rest.booter.ts). +[`packages/boot/src/model-api.booter.ts`](https://github.com/strongloop/loopback-next/blob/spike/crud-rest-booter/packages/boot/src/booters/model-api.booter.ts). Initially, I created a new package for this booter, but on the second thought, I think this booter is generic enough to be included in `@loopback/boot` under a different name `ModelApiBooter`. @@ -136,7 +136,7 @@ different name `ModelApiBooter`. The plugin (extension) contract is defined in two files: - [`packages/model-api-builder/src/model-api-builder.ts`](https://github.com/strongloop/loopback-next/blob/spike/crud-rest-booter/packages/model-api-builder/src/model-api-builder.ts) -- [`loopback-next/packages/model-api-builder/src/model-api-config.ts`](https://github.com/strongloop/loopback-next/blob/spike/crud-rest-booter/packages/model-api-builder/src/model-api-config.ts) +- [`packages/model-api-builder/src/model-api-config.ts`](https://github.com/strongloop/loopback-next/blob/spike/crud-rest-booter/packages/model-api-builder/src/model-api-config.ts) The CRUD REST API builder: [`packages/rest-crud/src/crud-rest-builder.plugin.ts`](https://github.com/strongloop/loopback-next/blob/spike/crud-rest-booter/packages/rest-crud/src/crud-rest-builder.plugin.ts) diff --git a/examples/todo/package.json b/examples/todo/package.json index fa9eda11f2e9..67f7cb3e0bf5 100644 --- a/examples/todo/package.json +++ b/examples/todo/package.json @@ -37,15 +37,13 @@ "license": "MIT", "dependencies": { "@loopback/boot": "^1.5.5", - "@loopback/booter-rest": "^0.0.1", "@loopback/context": "^1.22.1", "@loopback/core": "^1.10.1", "@loopback/openapi-v3": "^1.9.6", "@loopback/repository": "^1.13.1", "@loopback/rest": "^1.18.1", "@loopback/rest-crud": "^0.0.1", - "@loopback/rest-explorer": "^1.3.6", - "loopback-connector-rest": "^3.4.2" + "@loopback/rest-explorer": "^1.3.6" }, "devDependencies": { "@loopback/build": "^2.0.10", From 5f3fe525cd813e9bd40e94e0f210782edf647451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 10 Sep 2019 12:19:52 +0200 Subject: [PATCH 10/13] refactor: import models directly, change file/dir naming scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- _SPIKE_.md | 107 ++++++++++-------- examples/todo/src/application.ts | 8 -- .../todo.rest-config.ts} | 3 +- .../acceptance/model-api.booter.acceptance.ts | 42 +++---- .../src/__tests__/fixtures/product.model.ts | 15 +++ packages/boot/src/booters/model-api.booter.ts | 15 ++- .../model-api-builder/src/model-api-config.ts | 8 +- .../repository/src/mixins/repository.mixin.ts | 21 ---- 8 files changed, 105 insertions(+), 114 deletions(-) rename examples/todo/src/{public-models/todo.config.ts => model-endpoints/todo.rest-config.ts} (88%) create mode 100644 packages/boot/src/__tests__/fixtures/product.model.ts diff --git a/_SPIKE_.md b/_SPIKE_.md index d9e65df33083..b7323b55c80e 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -21,17 +21,18 @@ plugins to build repository & controller classes at runtime. ## Basic use -Create `src/public-models` directory in your project. For each model you want to -expose via REST API, add a new `.config.ts` file that's exporting the model -configuration. +Create `src/model-endpoints` directory in your project. For each model you want +to expose via REST API, add a new `.rest-config.ts` file that's exporting the +model configuration. -Example (`src/public-models/product.config.ts`): +Example (`src/public-models/product.rest-config.ts`): ```ts import {CrudRestApiConfig} from '@loopback/rest-crud'; +import {Product} from '../models'; module.exports = { - model: 'Product', + model: Product, pattern: 'CrudRest', dataSource: 'db', basePath: '/products', @@ -46,10 +47,12 @@ The solution has the following high-level parts: (extensions) contributing repository & controller builders. 2. A new booter `ModelApiBooter` that loads all JSON files from - `/public-models/{model-name}.config.json`, resolves model name into a model - class (via Application context), finds model api builder using - Extension/ExtensionPoint pattern and delegates the remaining work to the - plugin. + `/model-endpoints/{model-name}.{api-flavour}-config.json`, resolves model + name into a model class (via Application context), finds model api builder + using Extension/ExtensionPoint pattern and delegates the remaining work to + the plugin. The string `{api-flavour}` will be `rest` for the initial + implementation, but we will also support values like `grpc` or `graphql` for + protocols that may be implemented in the future. 3. An official model-api-builder plugin for building CRUD REST APIs using `DefaultCrudRepository` implementation. The plugin is implemented inside the @@ -75,10 +78,6 @@ stack traces include model name in function names. (Compare `ProductController.replaceById` with `CrudRestControllerImpl.replaceById` - which one is more useful?) -In my proposal, model-config files are in JSON format to make programmatic edits -easier. This has a downside in TypeScript projects - these config files must -live outside `src` because TypeScript does not copy arbitrary JSON files. - ## Extensibility & customization options The proposed design enables the following opportunities to extend and customize @@ -91,7 +90,7 @@ the default behavior of API endpoints: repository & controller builders provided by LB4 by their own logic. - Model configuration schema is extensible, individual plugins can define - additional model-config options to further tweak the behavior of API + additional model-endpoints options to further tweak the behavior of API endpoints. **Question:** @@ -117,8 +116,8 @@ authentication/authorization rules, then you can: pattern name (not `CrudRest`). 5. Bind your modified `ModelApiBuilder` to your app, so that the booter can find it. -6. In your model-config files, replace the `pattern` value from `CrudRest` to - the new builder name you choose in the step 4. +6. In your model-endpoints config files, replace the `pattern` value from + `CrudRest` to the new builder name you choose in the step 4. ## How to review the spike @@ -148,35 +147,59 @@ better support this spike. **Q: Where to keep model config files?** -- `/public-models/product.config.json` (JSON, must be outside src) -- `/src/public-models/product-config.ts` (TS, can be inside src, more flexible) +- `/model-endpoints/product.rest-config.json` (JSON, must be outside src) +- `/src/model-endpoints/product.rest-config.ts` (TS, can be inside src, more + flexible) **Answer:** -Let's keep them as TS files in `src/public-models`. I feel this is more +Let's keep them as TS files in `src/model-endpoints`. I feel this is more consistent with the approach we use for all other artifacts (models, repositories, etc.). It also enables application developers to conditionally customize model config, e.g. depending on `process.env` variables. **Q: Load models via DI, or rather let config files to load them via require?** +When models are loaded via DI, the config file specifies model as a string name: + ```ts -// in src/public-models/product-config.ts -{ - model: require('../models/product.model').Product, +module.exports = { + model: 'Product', +}; +``` + +When models are imported directly, the config file specifies model as a class: + +```ts +import {Product} from '../models/product.model'); + +module.exports = { + model: Product, // ... -} +}; ``` **Answer** -Load models via DI for consistency. We can add support for loading models via -`require` later, based on user demand. The change will be backwards-compatible. +Originally, I was proposing to load models via DI for consistency, leveraging a +new model booter to load & bind model classes from source code. + +It turns out such approach has a catch: to make it work, we need the ModelBooter +to be executed before ModelApiBooter, otherwise ModelApiBooter won't find the +models in the application context. The current boot implementation does not +support booter dependencies and I am concerned that it may be a non-trivial task +that would unnecessarily delay delivery of the model-api-booter feature. + +For the initial release of model-api-booter, I am proposing to import model +classes directly. + +We can add support for loading model classes via DI later, based on user demand. +Such change will be backwards-compatible. **Q: If we use TS files, then we can get rid of the extension point too** ```ts -// in src/public-models/product-config.ts +// in src/models-endpoints/product.rest-config.ts { model: require('../models/product.model').Product, pattern: require('@loopback/rest-crud').CrudRestApiBuilder, @@ -190,36 +213,20 @@ Load models via DI for consistency. We can add support for loading models via **Answer:** -Same as for models. Use DI in the initial implementation. Add support for -`require`-based approach later, based on user demand. +Let's use DI for consistency. We can add support for `require`-based approach +later, based on user demand. ## Tasks -1. Add `app.model(Model, name)` API to RepositoryMixin. - -- Q: Do we want to introduce `@model()` decorator for configuring dependency - injection? (Similar to `@repository`.) - - A: No, that would clash with `@model` exported by `@loopback/repository`. - -- Q: Do we want to rework scaffolded repositories to receive the model class via - DI? - - A: I feel that's preliminary at this point. Let's wait until we have a - (real-world) use case for that. - -2. Implement model booter to scan `dist/models/**/*.model.js` files and register - them by calling `app.model`. - -3. Implement `sandbox.writeTextFile` helper, include test coverage. +1. Implement `sandbox.writeTextFile` helper, include test coverage. -4. Improve `@loopback/rest-crud` to create a named controller class (modify +2. Improve `@loopback/rest-crud` to create a named controller class (modify `defineCrudRestController`) -5. Add `defineRepositoryClass` to `@loopback/rest-crud`, this function should +3. Add `defineRepositoryClass` to `@loopback/rest-crud`, this function should create a named repository class for the given Model class. -6. Implement Model API booter & builder. +4. Implement Model API booter & builder. - Add a new package `@loopback/model-api-builder`, copy the contents from this spike. Improve README with basic documentation for users (extension @@ -227,12 +234,12 @@ Same as for models. Use DI in the initial implementation. Add support for - Add `ModelApiBooter` to `@loopback/boot` -7. Add `CrudRestApiBuilder` to `@loopback/rest-crud`. Modify `README`, rework +5. Add `CrudRestApiBuilder` to `@loopback/rest-crud`. Modify `README`, rework "Basic use" to show how to the package together with `ModelApiBooter` to go from a model to REST API. Move the current content of "basic use" into a new section, e.g. "Advanced use". -8. Create a new example app based on the modified version of `examples/todo` +6. Create a new example app based on the modified version of `examples/todo` shown in the spike. The app should have a single `Todo` model and use `ModelApiBooter` to expose the model via REST API. diff --git a/examples/todo/src/application.ts b/examples/todo/src/application.ts index 8f0805122de1..4d4430bc785e 100644 --- a/examples/todo/src/application.ts +++ b/examples/todo/src/application.ts @@ -10,7 +10,6 @@ import {RestApplication} from '@loopback/rest'; import {CrudRestComponent} from '@loopback/rest-crud'; import {RestExplorerComponent} from '@loopback/rest-explorer'; import * as path from 'path'; -import {Todo} from './models'; import {MySequence} from './sequence'; export class TodoListApplication extends BootMixin( @@ -40,11 +39,4 @@ export class TodoListApplication extends BootMixin( }, }; } - - async boot(): Promise { - // temporary workaround for missing Model booter - this.model(Todo); - - return super.boot(); - } } diff --git a/examples/todo/src/public-models/todo.config.ts b/examples/todo/src/model-endpoints/todo.rest-config.ts similarity index 88% rename from examples/todo/src/public-models/todo.config.ts rename to examples/todo/src/model-endpoints/todo.rest-config.ts index a20647181ac5..1a0b834d0816 100644 --- a/examples/todo/src/public-models/todo.config.ts +++ b/examples/todo/src/model-endpoints/todo.rest-config.ts @@ -4,9 +4,10 @@ // License text available at https://opensource.org/licenses/MIT import {CrudRestApiConfig} from '@loopback/rest-crud'; +import {Todo} from '../models'; module.exports = { - model: 'Todo', + model: Todo, pattern: 'CrudRest', dataSource: 'db', basePath: '/todos', diff --git a/packages/boot/src/__tests__/acceptance/model-api.booter.acceptance.ts b/packages/boot/src/__tests__/acceptance/model-api.booter.acceptance.ts index 33bf76b5daac..80a12c314f2e 100644 --- a/packages/boot/src/__tests__/acceptance/model-api.booter.acceptance.ts +++ b/packages/boot/src/__tests__/acceptance/model-api.booter.acceptance.ts @@ -4,13 +4,9 @@ // License text available at https://opensource.org/licenses/MIT import {ApplicationConfig} from '@loopback/core'; -import {ModelApiConfig} from '@loopback/model-api-builder'; import { DefaultCrudRepository, - Entity, juggler, - model, - property, RepositoryMixin, } from '@loopback/repository'; import {RestApplication} from '@loopback/rest'; @@ -24,6 +20,7 @@ import { } from '@loopback/testlab'; import {resolve} from 'path'; import {BootMixin, ModelApiBooter} from '../..'; +import {Product} from '../fixtures/product.model'; describe('rest booter acceptance tests', () => { let app: BooterApp; @@ -36,28 +33,22 @@ describe('rest booter acceptance tests', () => { afterEach(stopApp); it('exposes models via CRUD REST API', async () => { - // Define the model. While we could do this via ModelBooter, it's usually - // easier to do so directly from code - the test is easier to read. - @model() - class Product extends Entity { - @property({id: true}) - id: number; - - @property({required: true}) - name: string; - } - app.model(Product); + await sandbox.copyFile( + resolve(__dirname, '../fixtures/product.model.js'), + 'models/product.model.js', + ); - // Write model-config file to specify how to expose the model via API - const cfg: ModelApiConfig = { - model: 'Product', - pattern: 'CrudRest', - dataSource: 'db', - basePath: '/products', - }; await sandbox.writeTextFile( - 'dist/public-models/product.config.js', - `module.exports = ${JSON.stringify(cfg, null, 2)}`, + '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 @@ -84,14 +75,13 @@ describe('rest booter acceptance tests', () => { class BooterApp extends BootMixin(RepositoryMixin(RestApplication)) { constructor(options?: ApplicationConfig) { super(options); - this.projectRoot = sandbox.path + '/dist'; + this.projectRoot = sandbox.path; this.booters(ModelApiBooter); this.component(CrudRestComponent); } } async function givenAppWithDataSource() { - await sandbox.mkdir('dist'); app = new BooterApp({ rest: givenHttpServerConfig(), }); diff --git a/packages/boot/src/__tests__/fixtures/product.model.ts b/packages/boot/src/__tests__/fixtures/product.model.ts new file mode 100644 index 000000000000..fe11f7cd037c --- /dev/null +++ b/packages/boot/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/boot/src/booters/model-api.booter.ts b/packages/boot/src/booters/model-api.booter.ts index e033a5177557..6bd6c1795ac7 100644 --- a/packages/boot/src/booters/model-api.booter.ts +++ b/packages/boot/src/booters/model-api.booter.ts @@ -16,7 +16,7 @@ import { ModelApiConfig, MODEL_API_BUILDER_PLUGINS, } from '@loopback/model-api-builder'; -import {ApplicationWithRepositories, Model} from '@loopback/repository'; +import {ApplicationWithRepositories} from '@loopback/repository'; import * as debugFactory from 'debug'; import * as path from 'path'; import {BootBindings} from '../keys'; @@ -72,9 +72,12 @@ export class ModelApiBooter extends BaseArtifactBooter { cfg, ); - const modelClass = await this.app.get( - `models.${cfg.model}`, - ); + const modelClass = cfg.model; + if (typeof modelClass !== 'function') { + throw new Error( + `Invalid "model" field. Expected a Model class, found ${modelClass}`, + ); + } const builder = await this.getApiBuilderForPattern(cfg.pattern); await builder.build(this.app, modelClass, cfg); @@ -98,7 +101,7 @@ export class ModelApiBooter extends BaseArtifactBooter { * Default ArtifactOptions for ControllerBooter. */ export const RestDefaults: ArtifactOptions = { - dirs: ['public-models'], - extensions: ['.config.js'], + dirs: ['model-endpoints'], + extensions: ['-config.js'], nested: true, }; diff --git a/packages/model-api-builder/src/model-api-config.ts b/packages/model-api-builder/src/model-api-config.ts index 89bafe912acb..4113d710a290 100644 --- a/packages/model-api-builder/src/model-api-config.ts +++ b/packages/model-api-builder/src/model-api-config.ts @@ -3,15 +3,19 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {Model} from '@loopback/repository'; + /** * Configuration settings for individual model files. This type describes * content of `public-models/{model-name}.config.json` files. */ export type ModelApiConfig = { - // E.g. 'Product' - model: string; + // E.g. Product (a Model class) + model: typeof Model & {prototype: Model}; + // E.g. 'RestCrud' pattern: string; + // E.g. 'db' dataSource: string; diff --git a/packages/repository/src/mixins/repository.mixin.ts b/packages/repository/src/mixins/repository.mixin.ts index 41acc6b1d8bc..e69fef960725 100644 --- a/packages/repository/src/mixins/repository.mixin.ts +++ b/packages/repository/src/mixins/repository.mixin.ts @@ -8,7 +8,6 @@ import {Application} from '@loopback/core'; import * as debugFactory from 'debug'; import {Class} from '../common-types'; import {SchemaMigrationOptions} from '../datasource'; -import {Model} from '../model'; import {juggler, Repository} from '../repositories'; const debug = debugFactory('loopback:repository:mixin'); @@ -89,22 +88,6 @@ export function RepositoryMixin>(superClass: T) { return this.get(`repositories.${repo.name}`); } - /** - * Register a model for dependency injection. - * @param modelClass The model or entity to add, e.g. `Product`. - * @param name Optional name to use for building the binding key, - * e.g. `BaseProduct`. - */ - model( - modelClass: Class, - name: string = modelClass.name, - ) { - const key = `models.${name}`; - return this.bind(key) - .to(modelClass) - .tag('model'); - } - /** * Add the dataSource to this application. * @@ -248,10 +231,6 @@ export interface ApplicationWithRepositories extends Application { ): Binding; // eslint-disable-next-line @typescript-eslint/no-explicit-any getRepository>(repo: Class): Promise; - model( - modelClass: Class, - name?: string, - ): Binding>; dataSource( dataSource: Class | D, name?: string, From 2969b5a5bdcfbb42e8b84ac28cb96e9c8c57886d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 10 Sep 2019 12:29:49 +0200 Subject: [PATCH 11/13] refactor: revert unnecessary changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- examples/todo/src/__tests__/acceptance/test-helper.ts | 4 ++-- examples/todo/src/index.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/todo/src/__tests__/acceptance/test-helper.ts b/examples/todo/src/__tests__/acceptance/test-helper.ts index eeba1d329a66..1806e3b1185d 100644 --- a/examples/todo/src/__tests__/acceptance/test-helper.ts +++ b/examples/todo/src/__tests__/acceptance/test-helper.ts @@ -3,12 +3,12 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {TodoListApplication} from '../..'; import { - Client, createRestAppClient, givenHttpServerConfig, + Client, } from '@loopback/testlab'; -import {TodoListApplication} from '../..'; export async function setupApplication(): Promise { const app = new TodoListApplication({ diff --git a/examples/todo/src/index.ts b/examples/todo/src/index.ts index 74832c1a9a79..d444402001a0 100644 --- a/examples/todo/src/index.ts +++ b/examples/todo/src/index.ts @@ -3,8 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {ApplicationConfig} from '@loopback/core'; import {TodoListApplication} from './application'; +import {ApplicationConfig} from '@loopback/core'; export async function main(options: ApplicationConfig = {}) { const app = new TodoListApplication(options); @@ -17,6 +17,7 @@ export async function main(options: ApplicationConfig = {}) { } // re-exports for our benchmark, not needed for the tutorial itself -export * from '@loopback/rest'; -export * from './models'; export {TodoListApplication}; + +export * from './models'; +export * from '@loopback/rest'; From 2848a1b3e4c62da88cfd3edc966e8267768cf326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 13 Sep 2019 09:58:00 +0200 Subject: [PATCH 12/13] feat: rename CrudRestApiConfig to ModelCrudRestApiConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- _SPIKE_.md | 4 ++-- examples/todo/src/model-endpoints/todo.rest-config.ts | 4 ++-- packages/rest-crud/src/crud-rest-builder.plugin.ts | 8 ++++---- packages/rest-crud/src/index.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/_SPIKE_.md b/_SPIKE_.md index b7323b55c80e..9ea10f53525e 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -28,10 +28,10 @@ model configuration. Example (`src/public-models/product.rest-config.ts`): ```ts -import {CrudRestApiConfig} from '@loopback/rest-crud'; +import {ModelCrudRestApiConfig} from '@loopback/rest-crud'; import {Product} from '../models'; -module.exports = { +module.exports = { model: Product, pattern: 'CrudRest', dataSource: 'db', diff --git a/examples/todo/src/model-endpoints/todo.rest-config.ts b/examples/todo/src/model-endpoints/todo.rest-config.ts index 1a0b834d0816..5597da633351 100644 --- a/examples/todo/src/model-endpoints/todo.rest-config.ts +++ b/examples/todo/src/model-endpoints/todo.rest-config.ts @@ -3,10 +3,10 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {CrudRestApiConfig} from '@loopback/rest-crud'; +import {ModelCrudRestApiConfig} from '@loopback/rest-crud'; import {Todo} from '../models'; -module.exports = { +module.exports = { model: Todo, pattern: 'CrudRest', dataSource: 'db', diff --git a/packages/rest-crud/src/crud-rest-builder.plugin.ts b/packages/rest-crud/src/crud-rest-builder.plugin.ts index 3e6a2e9ecdda..010257027bd0 100644 --- a/packages/rest-crud/src/crud-rest-builder.plugin.ts +++ b/packages/rest-crud/src/crud-rest-builder.plugin.ts @@ -22,7 +22,7 @@ import {defineRepositoryClass} from './repository-builder'; const debug = debugFactory('loopback:boot:crud-rest'); -export interface CrudRestApiConfig extends ModelApiConfig { +export interface ModelCrudRestApiConfig extends ModelApiConfig { // E.g. '/products' basePath: string; } @@ -37,7 +37,7 @@ export class CrudRestApiBuilder implements ModelApiBuilder { cfg: ModelApiConfig, ): Promise { const modelName = modelClass.name; - const config = cfg as CrudRestApiConfig; + const config = cfg as ModelCrudRestApiConfig; if (!config.basePath) { throw new Error( `Missing required field "basePath" in configuration for model ${modelName}.`, @@ -67,7 +67,7 @@ export class CrudRestApiBuilder implements ModelApiBuilder { function setupCrudRepository( entityClass: typeof Entity & {prototype: Entity}, - modelConfig: CrudRestApiConfig, + modelConfig: ModelCrudRestApiConfig, ): Class> { const repositoryClass = defineRepositoryClass(entityClass); @@ -82,7 +82,7 @@ function setupCrudRepository( function setupCrudRestController( entityClass: typeof Entity & {prototype: Entity}, - modelConfig: CrudRestApiConfig, + modelConfig: ModelCrudRestApiConfig, ): ControllerClass { const controllerClass = defineCrudRestController( entityClass, diff --git a/packages/rest-crud/src/index.ts b/packages/rest-crud/src/index.ts index 3b2f1f78c4ab..97a6476df972 100644 --- a/packages/rest-crud/src/index.ts +++ b/packages/rest-crud/src/index.ts @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -export {CrudRestApiConfig} from './crud-rest-builder.plugin'; +export * from './crud-rest-builder.plugin'; export * from './crud-rest.component'; export * from './crud-rest.controller'; export * from './repository-builder'; From d8fe4736fd07fc9da34ed345cbb77bdbd761b752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 13 Sep 2019 16:13:55 +0200 Subject: [PATCH 13/13] docs: add links to follow-up tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- _SPIKE_.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/_SPIKE_.md b/_SPIKE_.md index 9ea10f53525e..31d81220072c 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -220,12 +220,18 @@ later, based on user demand. 1. Implement `sandbox.writeTextFile` helper, include test coverage. + --> https://github.com/strongloop/loopback-next/issues/3731 + 2. Improve `@loopback/rest-crud` to create a named controller class (modify `defineCrudRestController`) + --> https://github.com/strongloop/loopback-next/issues/3732 + 3. Add `defineRepositoryClass` to `@loopback/rest-crud`, this function should create a named repository class for the given Model class. + --> https://github.com/strongloop/loopback-next/issues/3733 + 4. Implement Model API booter & builder. - Add a new package `@loopback/model-api-builder`, copy the contents from @@ -234,15 +240,21 @@ later, based on user demand. - Add `ModelApiBooter` to `@loopback/boot` + --> https://github.com/strongloop/loopback-next/issues/3736 + 5. Add `CrudRestApiBuilder` to `@loopback/rest-crud`. Modify `README`, rework "Basic use" to show how to the package together with `ModelApiBooter` to go from a model to REST API. Move the current content of "basic use" into a new section, e.g. "Advanced use". + --> https://github.com/strongloop/loopback-next/issues/3737 + 6. Create a new example app based on the modified version of `examples/todo` shown in the spike. The app should have a single `Todo` model and use `ModelApiBooter` to expose the model via REST API. + --> https://github.com/strongloop/loopback-next/issues/3738 + ### Out of scope - Infer base path (`/products`) from model name (`Product`). I'd like to