diff --git a/_SPIKE_.md b/_SPIKE_.md new file mode 100644 index 000000000000..31d81220072c --- /dev/null +++ b/_SPIKE_.md @@ -0,0 +1,263 @@ +# 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. + +## Basic use + +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.rest-config.ts`): + +```ts +import {ModelCrudRestApiConfig} from '@loopback/rest-crud'; +import {Product} from '../models'; + +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 + (extensions) contributing repository & controller builders. + +2. A new booter `ModelApiBooter` that loads all JSON files from + `/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 + 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?) + +## 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-endpoints 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-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 + +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/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`. + +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) +- [`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 answered: + +**Q: Where to keep model config files?** + +- `/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/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 +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** + +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/models-endpoints/product.rest-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:** + +Let's use DI for consistency. We can add support for `require`-based approach +later, based on user demand. + +## Tasks + +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 + this spike. Improve README with basic documentation for users (extension + developers building custom Model API Builders). + + - 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 + 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..67f7cb3e0bf5 100644 --- a/examples/todo/package.json +++ b/examples/todo/package.json @@ -42,14 +42,12 @@ "@loopback/openapi-v3": "^1.9.6", "@loopback/repository": "^1.13.1", "@loopback/rest": "^1.18.1", - "@loopback/rest-explorer": "^1.3.6", - "@loopback/service-proxy": "^1.3.5", - "loopback-connector-rest": "^3.4.2" + "@loopback/rest-crud": "^0.0.1", + "@loopback/rest-explorer": "^1.3.6" }, "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/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..4d4430bc785e 100644 --- a/examples/todo/src/application.ts +++ b/examples/todo/src/application.ts @@ -5,15 +5,15 @@ import {BootMixin} from '@loopback/boot'; 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 {MySequence} from './sequence'; export class TodoListApplication extends BootMixin( - ServiceMixin(RepositoryMixin(RestApplication)), + RepositoryMixin(RestApplication), ) { constructor(options: ApplicationConfig = {}) { super(options); @@ -26,6 +26,8 @@ export class TodoListApplication extends BootMixin( this.component(RestExplorerComponent); + this.component(CrudRestComponent); + this.projectRoot = __dirname; // Customize @loopback/boot Booter Conventions here this.bootOptions = { 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..d444402001a0 100644 --- a/examples/todo/src/index.ts +++ b/examples/todo/src/index.ts @@ -20,5 +20,4 @@ export async function main(options: ApplicationConfig = {}) { export {TodoListApplication}; export * from './models'; -export * from './repositories'; export * from '@loopback/rest'; diff --git a/examples/todo/src/model-endpoints/todo.rest-config.ts b/examples/todo/src/model-endpoints/todo.rest-config.ts new file mode 100644 index 000000000000..5597da633351 --- /dev/null +++ b/examples/todo/src/model-endpoints/todo.rest-config.ts @@ -0,0 +1,14 @@ +// 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 {ModelCrudRestApiConfig} from '@loopback/rest-crud'; +import {Todo} from '../models'; + +module.exports = { + model: Todo, + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/todos', +}; 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/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/boot/src/__tests__/acceptance/model-api.booter.acceptance.ts b/packages/boot/src/__tests__/acceptance/model-api.booter.acceptance.ts new file mode 100644 index 000000000000..80a12c314f2e --- /dev/null +++ b/packages/boot/src/__tests__/acceptance/model-api.booter.acceptance.ts @@ -0,0 +1,98 @@ +// 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 {ApplicationConfig} from '@loopback/core'; +import { + DefaultCrudRepository, + juggler, + 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 {BootMixin, ModelApiBooter} from '../..'; +import {Product} from '../fixtures/product.model'; + +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 () => { + await sandbox.copyFile( + resolve(__dirname, '../fixtures/product.model.js'), + 'models/product.model.js', + ); + + await sandbox.writeTextFile( + 'model-endpoints/product.rest-config.js', + ` +const {Product} = require('../models/product.model'); +module.exports = { + model: Product, + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/products', +}; + `, + ); + + // 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'}) + .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; + this.booters(ModelApiBooter); + this.component(CrudRestComponent); + } + } + + async function givenAppWithDataSource() { + app = new BooterApp({ + rest: givenHttpServerConfig(), + }); + app.dataSource(new juggler.DataSource({connector: 'memory'}), 'db'); + } + + async function stopApp() { + try { + await app.stop(); + } catch (err) { + console.error('Cannot stop the app, ignoring the error.', err); + } + } +}); 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/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/boot/src/booters/model-api.booter.ts b/packages/boot/src/booters/model-api.booter.ts new file mode 100644 index 000000000000..6bd6c1795ac7 --- /dev/null +++ b/packages/boot/src/booters/model-api.booter.ts @@ -0,0 +1,107 @@ +// 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 { + config, + CoreBindings, + extensionPoint, + extensions, + Getter, + inject, +} from '@loopback/core'; +import { + ModelApiBuilder, + ModelApiConfig, + MODEL_API_BUILDER_PLUGINS, +} from '@loopback/model-api-builder'; +import {ApplicationWithRepositories} 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:model-api'); + +@booter('modelApi') +@extensionPoint(MODEL_API_BUILDER_PLUGINS) +export class ModelApiBooter 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: ModelApiConfig = require(configFile); + debug( + 'Loaded model config from %s', + path.relative(this.projectRoot, configFile), + cfg, + ); + + 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); + } + + 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 = { + dirs: ['model-endpoints'], + extensions: ['-config.js'], + nested: true, +}; 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..714c64ef02d8 --- /dev/null +++ b/packages/model-api-builder/index.js @@ -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 + +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..dbcb6f7267f9 --- /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 + build( + 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..4113d710a290 --- /dev/null +++ b/packages/model-api-builder/src/model-api-config.ts @@ -0,0 +1,23 @@ +// 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 {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 (a Model class) + model: typeof Model & {prototype: Model}; + + // E.g. 'RestCrud' + pattern: string; + + // E.g. 'db' + dataSource: 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/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..010257027bd0 --- /dev/null +++ b/packages/rest-crud/src/crud-rest-builder.plugin.ts @@ -0,0 +1,101 @@ +// 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'); + +export interface ModelCrudRestApiConfig extends ModelApiConfig { + // E.g. '/products' + basePath: string; +} + +@bind(asModelApiBuilder) +export class CrudRestApiBuilder implements ModelApiBuilder { + readonly pattern: string = 'CrudRest'; + + build( + application: ApplicationWithRepositories, + modelClass: typeof Model & {prototype: Model}, + cfg: ModelApiConfig, + ): Promise { + const modelName = modelClass.name; + const config = cfg as ModelCrudRestApiConfig; + if (!config.basePath) { + throw new Error( + `Missing required field "basePath" in configuration for model ${modelName}.`, + ); + } + + if (!(modelClass.prototype instanceof Entity)) { + throw new Error( + `CrudRestController requires an Entity, Models are not supported. (Model name: ${modelName})`, + ); + } + 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: ModelCrudRestApiConfig, +): Class> { + const repositoryClass = defineRepositoryClass(entityClass); + + inject(`datasources.${modelConfig.dataSource}`)( + repositoryClass, + undefined, + 0, + ); + + return repositoryClass; +} + +function setupCrudRestController( + entityClass: typeof Entity & {prototype: Entity}, + modelConfig: ModelCrudRestApiConfig, +): 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..97a6476df972 100644 --- a/packages/rest-crud/src/index.ts +++ b/packages/rest-crud/src/index.ts @@ -3,4 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +export * from './crud-rest-builder.plugin'; +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; +} 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'}); + } }