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..f78c63f15825 --- /dev/null +++ b/packages/repository-rest/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2017. All Rights Reserved. +Node module: @loopback/repository +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..771cabc7031d --- /dev/null +++ b/packages/repository-rest/README.md @@ -0,0 +1,15 @@ +# @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..3eed349186bf --- /dev/null +++ b/packages/repository-rest/docs.json @@ -0,0 +1,11 @@ +{ + "content": [ + "./index.ts", + "./src/i**/*.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..1439c16c90db --- /dev/null +++ b/packages/repository-rest/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist/src'; diff --git a/packages/repository-rest/index.js b/packages/repository-rest/index.js new file mode 100644 index 000000000000..2c0a0c33fcd2 --- /dev/null +++ b/packages/repository-rest/index.js @@ -0,0 +1,9 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const nodeMajorVersion = +process.versions.node.split('.')[0]; +module.exports = nodeMajorVersion >= 7 ? + require('./dist/src') : + require('./dist6/src'); diff --git a/packages/repository-rest/index.ts b/packages/repository-rest/index.ts new file mode 100644 index 000000000000..313103704102 --- /dev/null +++ b/packages/repository-rest/index.ts @@ -0,0 +1,7 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: @loopback/repository +// 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..c9f43e31052c --- /dev/null +++ b/packages/repository-rest/package.json @@ -0,0 +1,49 @@ +{ + "name": "@loopback/repository-rest", + "version": "4.0.0-alpha.1", + "description": "Repository REST APIs for LoopBack", + "engines": { + "node": ">=6" + }, + "main": "index", + "scripts": { + "acceptance": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/acceptance/**/*.js'", + "build": "npm run build:dist && npm run build:dist6", + "build:current": "lb-tsc", + "build:dist": "lb-tsc es2017", + "build:dist6": "lb-tsc es2015", + "build:apidocs": "lb-apidocs", + "clean": "rm -rf loopback-context*.tgz dist* package", + "prepare": "npm run build && npm run build:apidocs", + "pretest": "npm run build:current", + "test": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/unit/**/*.js' 'DIST/test/acceptance/**/*.js' 'DIST/test/integration/**/*.js'", + "integration": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/integration/**/*.js'", + "unit": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/unit/**/*.js'", + "verify": "npm pack && tar xf loopback-juggler*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "license": "MIT", + "devDependencies": { + "@loopback/build": "^4.0.0-alpha.5", + "@loopback/testlab": "^4.0.0-alpha.14" + }, + "dependencies": { + "@loopback/context": "^4.0.0-alpha.20", + "@loopback/core": "^4.0.0-alpha.22", + "@loopback/repository": "^4.0.0-alpha.16", + "@loopback/rest": "^4.0.0-alpha.9" + }, + "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..90c234aeeb33 --- /dev/null +++ b/packages/repository-rest/src/controllers/crud-controller.ts @@ -0,0 +1,85 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository +// 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} from '@loopback/rest'; + +/** + * Base controller class to expose CrudRepository operations to REST + */ +export abstract class CrudController { + constructor(protected repository: CrudRepository) {} + + @post(`/`) + create( + @param({name: '', in: 'body'}) + dataObject: DataObject, + @param({name: 'options', required: false, in: 'query'}) + options?: Options, + ): Promise { + return this.repository.create(dataObject, options); + } + + @post(`/`) + createAll( + @param({name: '', in: 'body'}) + 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( + @param({name: '', in: 'body'}) + 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..28ca5626ceeb --- /dev/null +++ b/packages/repository-rest/src/controllers/entity-crud-controller.ts @@ -0,0 +1,156 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository +// 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} 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( + @param({name: '', in: 'body'}) + 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( + @param({name: '', in: 'body'}) + 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( + @param({name: '', in: 'body'}) + 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'}) + 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, + @param({name: '', in: 'body'}) + 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, + @param({name: '', in: 'body'}) + 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'}) + 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..a924f94de500 --- /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 +// 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..eb783c2eb0ce --- /dev/null +++ b/packages/repository-rest/src/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository +// 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..f4666479a0e9 --- /dev/null +++ b/packages/repository-rest/test/acceptance/crud-controller.ts @@ -0,0 +1,131 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: @loopback/repository +// 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, + param, +} 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..0817407909ac --- /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 +// 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..030808ee733b --- /dev/null +++ b/packages/repository-rest/test/integration/repository-controller.ts @@ -0,0 +1,111 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + repository, + DataSourceConstructor, + juggler, + Entity, + EntityCrudRepository, + DefaultCrudRepository, + ModelDefinition, +} from '@loopback/repository'; + +import {EntityCrudController} from '../..'; +import {ApplicationConfig, Application} from '@loopback/core'; +import {RestComponent, RestServer, api} from '@loopback/rest'; +import {createClientForHandler, supertest} from '@loopback/testlab'; + +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 DataSourceConstructor({ + name: 'db', + connector: 'memory', + }); + + class Note extends Entity { + 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) { + if (!options) options = {}; + options.components = [RestComponent]; + app = new Application(options); + return await app.getServer(RestServer); + } + + before(setup); + + before(() => { + client = createClientForHandler(server.handleHttp); + }); + + 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..855e02848b35 --- /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": ["src", "test"] +}