From 952d23e2a548569a6bd75026dbae7d9291dbf593 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 15 Nov 2017 15:20:12 -0800 Subject: [PATCH] feat: add REST mapping for repository interfaces --- packages/repository-rest/.gitignore | 3 + packages/repository-rest/.npmrc | 1 + packages/repository-rest/LICENSE | 25 ++++ packages/repository-rest/README.md | 16 ++ packages/repository-rest/docs.json | 11 ++ packages/repository-rest/index.d.ts | 6 + packages/repository-rest/index.js | 6 + packages/repository-rest/index.ts | 7 + packages/repository-rest/package.json | 46 ++++++ .../src/controllers/crud-controller.ts | 72 +++++++++ .../src/controllers/entity-crud-controller.ts | 137 ++++++++++++++++++ .../repository-rest/src/controllers/index.ts | 7 + packages/repository-rest/src/index.ts | 6 + .../test/acceptance/crud-controller.ts | 130 +++++++++++++++++ .../test/acceptance/entity-crud-controller.ts | 67 +++++++++ .../test/integration/repository-controller.ts | 117 +++++++++++++++ packages/repository-rest/tsconfig.build.json | 8 + 17 files changed, 665 insertions(+) create mode 100644 packages/repository-rest/.gitignore create mode 100644 packages/repository-rest/.npmrc create mode 100644 packages/repository-rest/LICENSE create mode 100644 packages/repository-rest/README.md create mode 100644 packages/repository-rest/docs.json create mode 100644 packages/repository-rest/index.d.ts create mode 100644 packages/repository-rest/index.js create mode 100644 packages/repository-rest/index.ts create mode 100644 packages/repository-rest/package.json create mode 100644 packages/repository-rest/src/controllers/crud-controller.ts create mode 100644 packages/repository-rest/src/controllers/entity-crud-controller.ts create mode 100644 packages/repository-rest/src/controllers/index.ts create mode 100644 packages/repository-rest/src/index.ts create mode 100644 packages/repository-rest/test/acceptance/crud-controller.ts create mode 100644 packages/repository-rest/test/acceptance/entity-crud-controller.ts create mode 100644 packages/repository-rest/test/integration/repository-controller.ts create mode 100644 packages/repository-rest/tsconfig.build.json diff --git a/packages/repository-rest/.gitignore b/packages/repository-rest/.gitignore new file mode 100644 index 000000000000..90a8d96cc3ff --- /dev/null +++ b/packages/repository-rest/.gitignore @@ -0,0 +1,3 @@ +*.tgz +dist* +package diff --git a/packages/repository-rest/.npmrc b/packages/repository-rest/.npmrc new file mode 100644 index 000000000000..43c97e719a5a --- /dev/null +++ b/packages/repository-rest/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/repository-rest/LICENSE b/packages/repository-rest/LICENSE new file mode 100644 index 000000000000..81672e65350c --- /dev/null +++ b/packages/repository-rest/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2018. All Rights Reserved. +Node module: @loopback/repository-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/repository-rest/README.md b/packages/repository-rest/README.md new file mode 100644 index 000000000000..fb8ea384daf0 --- /dev/null +++ b/packages/repository-rest/README.md @@ -0,0 +1,16 @@ +# @loopback/repository-rest + +This module provides base controllers to map repository interfaces to REST APIs + +## 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/repository-rest/docs.json b/packages/repository-rest/docs.json new file mode 100644 index 000000000000..dcafef2456c6 --- /dev/null +++ b/packages/repository-rest/docs.json @@ -0,0 +1,11 @@ +{ + "content": [ + "./index.ts", + "./src/**/*.ts" + ], + "codeSectionDepth": 4, + "assets": { + "/": "/docs", + "/docs": "/docs" + } +} diff --git a/packages/repository-rest/index.d.ts b/packages/repository-rest/index.d.ts new file mode 100644 index 000000000000..be20129cf608 --- /dev/null +++ b/packages/repository-rest/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist8/src'; diff --git a/packages/repository-rest/index.js b/packages/repository-rest/index.js new file mode 100644 index 000000000000..9ab5cbf72bc1 --- /dev/null +++ b/packages/repository-rest/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +module.exports = require('@loopback/dist-util').loadDist(__dirname); diff --git a/packages/repository-rest/index.ts b/packages/repository-rest/index.ts new file mode 100644 index 000000000000..43b14acbe186 --- /dev/null +++ b/packages/repository-rest/index.ts @@ -0,0 +1,7 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: @loopback/repository-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// NOTE(bajtos) This file is used by VSCode/TypeScriptServer at dev time only +export * from './src'; diff --git a/packages/repository-rest/package.json b/packages/repository-rest/package.json new file mode 100644 index 000000000000..8da0b22b109c --- /dev/null +++ b/packages/repository-rest/package.json @@ -0,0 +1,46 @@ +{ + "name": "@loopback/repository-rest", + "version": "0.1.0", + "description": "Repository REST APIs for LoopBack", + "engines": { + "node": ">=8.9" + }, + "main": "index", + "scripts": { + "build:all-dist": "npm run build:dist8 && npm run build:dist10", + "build:apidocs": "lb-apidocs", + "build": "lb-tsc", + "build:dist8": "lb-tsc es2017", + "build:dist10": "lb-tsc es2018", + "clean": "lb-clean loopback-repository-rest*.tgz dist* package api-docs", + "pretest": "npm run build", + "test": "lb-mocha \"DIST/test/unit/**/*.js\" \"DIST/test/integration/**/*.js\" \"DIST/test/acceptance/**/*.js\"", + "verify": "npm pack && tar xf loopback-repository-rest*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "license": "MIT", + "devDependencies": { + "@loopback/build": "^0.7.3", + "@loopback/testlab": "^0.12.2" + }, + "dependencies": { + "@loopback/context": "^0.12.8", + "@loopback/core": "^0.11.9", + "@loopback/dist-util": "^0.3.7", + "@loopback/repository": "^0.16.5", + "@loopback/rest": "^0.22.2" + }, + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist/src", + "dist6/src", + "api-docs", + "src" + ], + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + } +} diff --git a/packages/repository-rest/src/controllers/crud-controller.ts b/packages/repository-rest/src/controllers/crud-controller.ts new file mode 100644 index 000000000000..173c0ec633bf --- /dev/null +++ b/packages/repository-rest/src/controllers/crud-controller.ts @@ -0,0 +1,72 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + CrudRepository, + ValueObject, + Entity, + DataObject, + Options, + Filter, + Where, +} from '@loopback/repository'; + +import {post, get, param, requestBody} from '@loopback/rest'; + +/** + * Base controller class to expose CrudRepository operations to REST + */ +export abstract class CrudController { + constructor(protected repository: CrudRepository) {} + + @post(`/`) + create( + @requestBody() dataObject: DataObject, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.create(dataObject, options); + } + + @post(`/`) + createAll( + @requestBody() dataObjects: DataObject[], + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.createAll(dataObjects, options); + } + + @get(`/`) + find( + @param({name: 'filter', required: false, in: 'query'}) filter?: Filter, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.find(filter, options); + } + + @post(`/updateAll`) + updateAll( + @requestBody() dataObject: DataObject, + @param({name: 'where', required: false, in: 'query'}) where?: Where, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.updateAll(dataObject, where, options); + } + + @post(`/deleteAll`) + deleteAll( + @param({name: 'where', required: false, in: 'query'}) where?: Where, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.deleteAll(where, options); + } + + @get(`/count`) + count( + @param({name: 'where', required: false, in: 'query'}) where?: Where, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.count(where, options); + } +} diff --git a/packages/repository-rest/src/controllers/entity-crud-controller.ts b/packages/repository-rest/src/controllers/entity-crud-controller.ts new file mode 100644 index 000000000000..ca140260b0f9 --- /dev/null +++ b/packages/repository-rest/src/controllers/entity-crud-controller.ts @@ -0,0 +1,137 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Entity, + DataObject, + Options, + Filter, + EntityCrudRepository, +} from '@loopback/repository'; + +import {post, put, patch, get, del, param, requestBody} from '@loopback/rest'; + +import {CrudController} from './crud-controller'; + +/** + * Base controller class to expose CrudRepository operations to REST + */ +export abstract class EntityCrudController< + T extends Entity, + ID +> extends CrudController { + constructor(protected repository: EntityCrudRepository) { + super(repository); + } + + @put(`/save`) + save( + @requestBody() entity: DataObject, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.save(entity, options); + } + + @post(`/update`, { + responses: { + '200': { + description: 'The instance is updated successfully', + schema: {type: 'boolean'}, + }, + }, + }) + update( + @requestBody() entity: DataObject, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.update(entity, options); + } + + @post(`/delete`, { + responses: { + '200': { + description: 'The instance is deleted successfully', + schema: {type: 'boolean'}, + }, + }, + }) + delete( + @requestBody() entity: DataObject, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.update(entity, options); + } + + @get(`/{id}`) + findById( + @param({name: 'id', in: 'path', schema: {type: 'string'}}) id: ID, + @param({name: 'filter', required: false, in: 'query'}) filter?: Filter, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.findById(id, filter, options); + } + + @patch(`/{id}`, { + responses: { + '200': { + description: 'The instance is updated successfully', + schema: {type: 'boolean'}, + }, + }, + }) + updateById( + @param({name: 'id', in: 'path'}) id: ID, + @requestBody() data: DataObject, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.updateById(id, data, options); + } + + @put(`/{id}`, { + responses: { + '200': { + description: 'The instance is replaced successfully', + schema: {type: 'boolean'}, + }, + }, + }) + replaceById( + @param({name: 'id', in: 'path'}) id: ID, + @requestBody() data: DataObject, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.replaceById(id, data, options); + } + + @del(`{id}`, { + responses: { + '200': { + description: 'The instance is deleted successfully', + schema: {type: 'boolean'}, + }, + }, + }) + deleteById( + @param({name: 'id', in: 'path'}) id: ID, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.deleteById(id, options); + } + + @get(`/{id}/exists`, { + responses: { + '200': { + description: 'The id exists for an instance', + schema: {type: 'boolean'}, + }, + }, + }) + exists( + @param({name: 'id', in: 'path', schema: {type: 'string'}}) id: ID, + @param({name: 'options', required: false, in: 'query'}) options?: Options, + ): Promise { + return this.repository.exists(id, options); + } +} diff --git a/packages/repository-rest/src/controllers/index.ts b/packages/repository-rest/src/controllers/index.ts new file mode 100644 index 000000000000..cff0bdc63469 --- /dev/null +++ b/packages/repository-rest/src/controllers/index.ts @@ -0,0 +1,7 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './crud-controller'; +export * from './entity-crud-controller'; diff --git a/packages/repository-rest/src/index.ts b/packages/repository-rest/src/index.ts new file mode 100644 index 000000000000..f7fb56c7248e --- /dev/null +++ b/packages/repository-rest/src/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './controllers'; diff --git a/packages/repository-rest/test/acceptance/crud-controller.ts b/packages/repository-rest/test/acceptance/crud-controller.ts new file mode 100644 index 000000000000..698ab510883f --- /dev/null +++ b/packages/repository-rest/test/acceptance/crud-controller.ts @@ -0,0 +1,130 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: @loopback/repository-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + repository, + CrudRepository, + ValueObject, + DataObject, + Options, + Where, +} from '@loopback/repository'; +import { + api, + getControllerSpec, + ControllerSpec, + HttpErrors, + put, +} from '@loopback/rest'; +import {expect} from '@loopback/testlab'; + +import {CrudController} from '../..'; + +describe('CrudController', () => { + class Address extends ValueObject {} + + @api({basePath: '/addresses', paths: {}}) + class AddressController extends CrudController
{ + constructor(@repository('addressRepo') repo: CrudRepository
) { + super(repo); + } + } + + it('registers CRUD operations', () => { + const spec = getControllerSpec(AddressController); + const ops = getOperations(spec); + expect(ops).to.eql([ + 'get /: find', + 'post /: createAll', + 'post /updateAll: updateAll', + 'post /deleteAll: deleteAll', + 'get /count: count', + ]); + }); +}); + +describe('CrudController with overrides', () => { + class Address extends ValueObject {} + + @api({basePath: '/addresses', paths: {}}) + class AddressController extends CrudController
{ + constructor(@repository('addressRepo') repo: CrudRepository
) { + super(repo); + } + + /** + * An example to add pre/post processing logic for the base method + */ + async create( + dataObject: DataObject
, + options?: Options, + ): Promise
{ + console.log('Creating address %j', dataObject); + const address = await super.create(dataObject, options); + console.log('Address created: %j', address); + return address; + } + + /** + * An example to disable an HTTP route exposed by the base method + */ + deleteAll() { + // Disable the `deleteAll` route + return Promise.reject(new HttpErrors.NotFound()); + } + + /** + * An example to override `verb` and/or `path` + */ + @put(`/updateAll`) + updateAll( + dataObject: DataObject
, + where?: Where, + options?: Options, + ): Promise { + return super.updateAll(dataObject, where, options); + } + } + + it('registers CRUD operations', () => { + const spec = getControllerSpec(AddressController); + const ops = getOperations(spec); + expect(ops).to.eql([ + 'get /: find', + 'post /: createAll', + 'put /updateAll: updateAll', + 'post /deleteAll: deleteAll', + 'get /count: count', + ]); + }); +}); + +/** + * Build an array of readable routes from the controller spec + * @param spec Controller spec + */ +function getOperations(spec: ControllerSpec): string[] { + const operations: string[] = []; + for (const p in spec.paths) { + const path = spec.paths[p]; + let verb, operationName; + for (const v of [ + 'delete', + 'get', + 'head', + 'options', + 'patch', + 'post', + 'put', + ]) { + if (v in path) { + verb = v; + operationName = path[v]['x-operation-name']; + operations.push(`${verb} ${p}: ${operationName}`); + } + } + } + return operations; +} diff --git a/packages/repository-rest/test/acceptance/entity-crud-controller.ts b/packages/repository-rest/test/acceptance/entity-crud-controller.ts new file mode 100644 index 000000000000..5fb685e37ca8 --- /dev/null +++ b/packages/repository-rest/test/acceptance/entity-crud-controller.ts @@ -0,0 +1,67 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: @loopback/repository-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {EntityCrudController} from '../..'; +import {Entity, repository, EntityCrudRepository} from '@loopback/repository'; +import {api, getControllerSpec, ControllerSpec} from '@loopback/rest'; +import {expect} from '@loopback/testlab'; + +describe('EntityCrudController', () => { + class Customer extends Entity {} + + @api({basePath: '/customers', paths: {}}) + class CustomerController extends EntityCrudController { + constructor( + @repository('customerRepo') repo: EntityCrudRepository, + ) { + super(repo); + } + } + + it('registers CRUD operations', () => { + const spec = getControllerSpec(CustomerController); + expect(spec.basePath).to.equal('/customers'); + const ops = getOperations(spec); + expect(ops).to.eql([ + 'get /: find', + 'post /: createAll', + 'post /updateAll: updateAll', + 'post /deleteAll: deleteAll', + 'get /count: count', + 'put /save: save', + 'post /update: update', + 'post /delete: delete', + 'get /{id}: findById', + 'patch /{id}: updateById', + 'put /{id}: replaceById', + 'delete {id}: deleteById', + 'get /{id}/exists: exists', + ]); + }); + + function getOperations(spec: ControllerSpec) { + const operations: string[] = []; + for (const p in spec.paths) { + const path = spec.paths[p]; + let verb, operationName; + for (const v of [ + 'delete', + 'get', + 'head', + 'options', + 'patch', + 'post', + 'put', + ]) { + if (v in path) { + verb = v; + operationName = path[v]['x-operation-name']; + operations.push(`${verb} ${p}: ${operationName}`); + } + } + } + return operations; + } +}); diff --git a/packages/repository-rest/test/integration/repository-controller.ts b/packages/repository-rest/test/integration/repository-controller.ts new file mode 100644 index 000000000000..57f1c662fef1 --- /dev/null +++ b/packages/repository-rest/test/integration/repository-controller.ts @@ -0,0 +1,117 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + repository, + juggler, + Entity, + EntityCrudRepository, + DefaultCrudRepository, +} from '@loopback/repository'; + +import {EntityCrudController} from '../..'; +import {ApplicationConfig, Application} from '@loopback/core'; +import {RestApplication, RestServer, api} from '@loopback/rest'; +import {createClientForHandler, supertest} from '@loopback/testlab'; +import {model, property} from '@loopback/repository'; + +describe('Repository based controller', () => { + let app: Application; + let server: RestServer; + let client: supertest.SuperTest; + + // The Controller for Note + @api({basePath: '/notes', paths: {}}) + class NoteController extends EntityCrudController { + constructor( + @repository('noteRepo') noteRepo: EntityCrudRepository, + ) { + super(noteRepo); + } + } + + const ds: juggler.DataSource = new juggler.DataSource({ + name: 'db', + connector: 'memory', + }); + + @model() + class Note extends Entity { + @property({id: true}) + id: number; + @property() + title: string; + @property() + content: string; + /* + static definition = new ModelDefinition({ + name: 'note', + properties: { + id: {type: 'number', id: true}, + title: 'string', + content: 'string', + }, + }); + */ + } + + async function setup() { + server = await createServer({rest: {port: 0}}); + + // Mock up a predefined repository + const repo = new DefaultCrudRepository(Note, ds); + + // Bind the repository instance + server.bind('repositories.noteRepo').to(repo); + + // Bind the controller class + app.controller(NoteController); + + // Create some notes + await repo.create({title: 't1', content: 'Note 1'}); + await repo.create({title: 't2', content: 'Note 2'}); + + await server.start(); + } + + async function createServer(options?: ApplicationConfig) { + app = new RestApplication(options); + return await app.getServer(RestServer); + } + + before(setup); + + before(() => { + client = createClientForHandler(server.requestHandler); + }); + + after(async () => { + await server.stop(); + }); + + it('exposes GET /notes', async () => { + await client + .get('/notes') + .expect('Content-Type', 'application/json') + .expect(200, [ + {id: 1, title: 't1', content: 'Note 1'}, + {id: 2, title: 't2', content: 'Note 2'}, + ]); + }); + + it('exposes GET /notes/{id}', async () => { + await client + .get('/notes/1') + .expect(200, {id: 1, title: 't1', content: 'Note 1'}); + }); + + it('exposes GET /notes/{id}/exists', async () => { + await client.get('/notes/1/exists').expect(200, 'true'); + }); + + it('exposes GET /notes/count', async () => { + await client.get('/notes/count').expect(200, '2'); + }); +}); diff --git a/packages/repository-rest/tsconfig.build.json b/packages/repository-rest/tsconfig.build.json new file mode 100644 index 000000000000..3ffcd508d23e --- /dev/null +++ b/packages/repository-rest/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../build/config/tsconfig.common.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["index.ts", "src", "test"] +}