From 959e4eed383382487efedf5d8a36b8ec058f2a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 20 Aug 2019 14:24:52 +0200 Subject: [PATCH] feat(rest-crud): initial implementation Introduce a new package `@loopback/rest-crud` providing a controller class implementing default CRUD operations for a given Entity. --- CODEOWNERS | 1 + docs/site/DEVELOPING.md | 1 - docs/site/MONOREPO.md | 1 + packages/rest-crud/.npmrc | 1 + packages/rest-crud/LICENSE | 25 ++ packages/rest-crud/README.md | 65 ++++ packages/rest-crud/index.d.ts | 6 + packages/rest-crud/index.js | 6 + packages/rest-crud/index.ts | 8 + packages/rest-crud/package-lock.json | 14 + packages/rest-crud/package.json | 46 +++ .../default-model-crud-rest.acceptance.ts | 292 ++++++++++++++++ .../rest-crud/src/crud-rest.controller.ts | 320 ++++++++++++++++++ packages/rest-crud/src/index.ts | 6 + packages/rest-crud/tsconfig.build.json | 9 + 15 files changed, 800 insertions(+), 1 deletion(-) create mode 100644 packages/rest-crud/.npmrc create mode 100644 packages/rest-crud/LICENSE create mode 100644 packages/rest-crud/README.md create mode 100644 packages/rest-crud/index.d.ts create mode 100644 packages/rest-crud/index.js create mode 100644 packages/rest-crud/index.ts create mode 100644 packages/rest-crud/package-lock.json create mode 100644 packages/rest-crud/package.json create mode 100644 packages/rest-crud/src/__tests__/acceptance/default-model-crud-rest.acceptance.ts create mode 100644 packages/rest-crud/src/crud-rest.controller.ts create mode 100644 packages/rest-crud/src/index.ts create mode 100644 packages/rest-crud/tsconfig.build.json diff --git a/CODEOWNERS b/CODEOWNERS index e10caf7a1968..de5690a9aaf7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -26,6 +26,7 @@ packages/repository/* @raymondfeng packages/repository-tests/* @bajtos packages/repository-json-schema/* @bajtos packages/rest/* @bajtos @raymondfeng +packages/rest-crud/* @bajtos @hacksparrow packages/service-proxy/* @raymondfeng packages/testlab/* @bajtos packages/tsdocs/* @raymondfeng diff --git a/docs/site/DEVELOPING.md b/docs/site/DEVELOPING.md index 9ac71a567574..e783dcf46ad9 100644 --- a/docs/site/DEVELOPING.md +++ b/docs/site/DEVELOPING.md @@ -491,7 +491,6 @@ Please register the new package in the following files: - Update [MONOREPO.md](./MONOREPO.md) - insert a new table row to describe the new package, please keep the rows sorted by package name. -- Update - Update [Reserved-binding-keys.md](./Reserved-binding-keys.md) - add a link to the apidocs on Binding Keys if the new package has any. - Update diff --git a/docs/site/MONOREPO.md b/docs/site/MONOREPO.md index 6e8d7b50bba9..6a122f479046 100644 --- a/docs/site/MONOREPO.md +++ b/docs/site/MONOREPO.md @@ -37,6 +37,7 @@ The [loopback-next](https://github.com/strongloop/loopback-next) repository uses | [repository](https://github.com/strongloop/loopback-next/tree/master/packages/repository) | @loopback/repository | Define and implement a common set of interfaces for interacting with databases | | [repository-tests](https://github.com/strongloop/loopback-next/tree/master/packages/repository-tests) | @loopback/repository-tests | A shared test suite to verify `@loopback/repository` functionality with a given compatible connector | | [rest](https://github.com/strongloop/loopback-next/tree/master/packages/rest) | @loopback/rest | Expose controllers as REST endpoints and route REST API requests to controller methods | +| [rest-crud](https://github.com/strongloop/loopback-next/tree/master/packages/rest-crud) | @loopback/rest-crud | REST API controller implementing default CRUD semantics | | [service-proxy](https://github.com/strongloop/loopback-next/tree/master/packages/service-proxy) | @loopback/service-proxy | A common set of interfaces for interacting with service oriented backends such as REST APIs, SOAP Web Services, and gRPC microservices | | [testlab](https://github.com/strongloop/loopback-next/tree/master/packages/testlab) | @loopback/testlab | A collection of test utilities we use to write LoopBack tests | | [tsdocs](https://github.com/strongloop/loopback-next/tree/master/packages/tsdocs) | @loopback/tsdocs | An internal package to generate api docs using Microsoft api-extractor and api-documenter | diff --git a/packages/rest-crud/.npmrc b/packages/rest-crud/.npmrc new file mode 100644 index 000000000000..cafe685a112d --- /dev/null +++ b/packages/rest-crud/.npmrc @@ -0,0 +1 @@ +package-lock=true diff --git a/packages/rest-crud/LICENSE b/packages/rest-crud/LICENSE new file mode 100644 index 000000000000..f992865d93ef --- /dev/null +++ b/packages/rest-crud/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2019. All Rights Reserved. +Node module: @loopback/rest-crud +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/rest-crud/README.md b/packages/rest-crud/README.md new file mode 100644 index 000000000000..778a8d542f75 --- /dev/null +++ b/packages/rest-crud/README.md @@ -0,0 +1,65 @@ +# @loopback/rest-crud + +REST API controller implementing default CRUD semantics. + +## Overview + +This module allows applications to quickly expose a model via REST API without +having to implement a custom controller class. + +## Installation + +```sh +npm install --save @loopback/rest-crud +``` + +## Basic use + +1. Define your model class, e.g. using `lb4 model` tool. + +2. Create a Repository class, e.g. using `lb4 repository` tool. + +3. Create a base REST CRUD controller class for your model. + + ```ts + const CrudRestController = defineCrudRestController< + Product, + typeof Product.prototype.id, + 'id' + >(Product, {basePath: '/products'}); + ``` + +4. Create a new subclass of the base controller class to configure repository + injection. + + ```ts + class ProductController extends CrudRestController { + constructor(@repository(ProductRepository) repo: ProductRepository) { + super(repo); + } + } + ``` + +5. Register the controller with your application. + + ```ts + app.controller(ProductController); + ``` + +## 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/rest-crud/index.d.ts b/packages/rest-crud/index.d.ts new file mode 100644 index 000000000000..1e9038f1b84d --- /dev/null +++ b/packages/rest-crud/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/crud-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/packages/rest-crud/index.js b/packages/rest-crud/index.js new file mode 100644 index 000000000000..cb1213dd056d --- /dev/null +++ b/packages/rest-crud/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/crud-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +module.exports = require('./dist'); diff --git a/packages/rest-crud/index.ts b/packages/rest-crud/index.ts new file mode 100644 index 000000000000..817967421b0e --- /dev/null +++ b/packages/rest-crud/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/crud-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// DO NOT EDIT THIS FILE +// Add any additional (re)exports to src/index.ts instead. +export * from './src'; diff --git a/packages/rest-crud/package-lock.json b/packages/rest-crud/package-lock.json new file mode 100644 index 000000000000..346301a7d3d4 --- /dev/null +++ b/packages/rest-crud/package-lock.json @@ -0,0 +1,14 @@ +{ + "name": "@loopback/rest-crud", + "version": "0.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/node": { + "version": "10.14.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.15.tgz", + "integrity": "sha512-CBR5avlLcu0YCILJiDIXeU2pTw7UK/NIxfC63m7d7CVamho1qDEzXKkOtEauQRPMy6MI8mLozth+JJkas7HY6g==", + "dev": true + } + } +} diff --git a/packages/rest-crud/package.json b/packages/rest-crud/package.json new file mode 100644 index 000000000000..4808ab913360 --- /dev/null +++ b/packages/rest-crud/package.json @@ -0,0 +1,46 @@ +{ + "name": "@loopback/rest-crud", + "version": "0.0.1", + "description": "REST API controller implementing default CRUD semantics", + "engines": { + "node": ">=8.9" + }, + "main": "index", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "lb-tsc", + "clean": "lb-clean loopback-rest-crud*.tgz dist tsconfig.build.tsbuildinfo package", + "pretest": "npm run build", + "test": "lb-mocha \"dist/__tests__/**/*.js\"", + "verify": "npm pack && tar xf loopback-rest-crud*.tgz && tree package && npm run clean" + }, + "author": "IBM Corp.", + "copyright.owner": "IBM Corp.", + "license": "MIT", + "devDependencies": { + "@loopback/build": "^1.7.1", + "@loopback/repository": "^1.12.0", + "@loopback/rest": "^1.17.0", + "@loopback/testlab": "^1.7.4", + "@types/node": "^10.14.15" + }, + "peerDependencies": { + "@loopback/repository": "^1.12.0", + "@loopback/rest": "^1.17.0" + }, + "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-crud" + } +} diff --git a/packages/rest-crud/src/__tests__/acceptance/default-model-crud-rest.acceptance.ts b/packages/rest-crud/src/__tests__/acceptance/default-model-crud-rest.acceptance.ts new file mode 100644 index 000000000000..70879de9ef19 --- /dev/null +++ b/packages/rest-crud/src/__tests__/acceptance/default-model-crud-rest.acceptance.ts @@ -0,0 +1,292 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/crud-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + DefaultCrudRepository, + Entity, + EntityCrudRepository, + juggler, + model, + property, +} from '@loopback/repository'; +import {RestApplication} from '@loopback/rest'; +import { + Client, + createRestAppClient, + expect, + givenHttpServerConfig, + toJSON, +} from '@loopback/testlab'; +import {defineCrudRestController} from '../..'; + +// In this test scenario, we create a product with a required & an optional +// property and use the default model settings (strict mode, forceId). +// +// Please don't add any other scenarios to this test file, create a new file +// for each scenario instead. + +describe('CrudRestController for a simple Product model', () => { + @model() + class Product extends Entity { + @property({id: true}) + id: number; + + @property({required: true}) + name: string; + + @property() + description?: string; + + constructor(data: Partial) { + super(data); + } + } + + let app: RestApplication; + let repo: EntityCrudRepository; + let client: Client; + + // sample data - call `seedData` to populate these items + let pen: Product; + let pencil: Product; + + const PATCH_DATA = {description: 'patched'}; + Object.freeze(PATCH_DATA); + + before(setupTestScenario); + after(stopTheApp); + beforeEach(cleanDatabase); + + describe('create', () => { + it('creates a new Product', async () => { + const response = await client + .post('/products') + .send({name: 'A pen'}) + // FIXME: POST should return 201 + // See https://github.com/strongloop/loopback-next/issues/788 + .expect(200); + + const created = response.body; + expect(created).to.containEql({name: 'A pen'}); + expect(created) + .to.have.property('id') + .of.type('number'); + + const found = (await repo.find())[0]; + expect(toJSON(found)).to.deepEqual(created); + }); + + it('rejects request with `id` value', async () => { + await client + .post('/products') + .send({id: 1, name: 'a name'}) + .expect(422); + }); + }); + + describe('find', () => { + beforeEach(seedData); + + it('returns all products when no filter was provided', async () => { + const {body} = await client.get('/products').expect(200); + expect(body).to.deepEqual(toJSON([pen, pencil])); + }); + + it('supports `where` filter', async () => { + const {body} = await client + .get('/products') + .query({'filter[where][name]': pen.name}) + .expect(200); + expect(body).to.deepEqual(toJSON([pen /* pencil was omitted */])); + }); + }); + + describe('findById', () => { + beforeEach(seedData); + + it('returns model with the given id', async () => { + const {body} = await client.get(`/products/${pen.id}`).expect(200); + expect(body).to.deepEqual(toJSON(pen)); + }); + + // TODO(bajtos) to fully verify this functionality, we should create + // a new test suite that will configure a PK with a different name + // and type, e.g. `pk: string` instead of `id: number`. + it('uses correct schema for the id parameter', async () => { + const spec = app.restServer.getApiSpec(); + const findByIdOp = spec.paths['/products/{id}'].get; + expect(findByIdOp).to.containDeep({ + parameters: [ + { + name: 'id', + in: 'path', + schema: {type: 'number'}, + }, + ], + }); + }); + }); + + describe('count', () => { + beforeEach(seedData); + + it('returns all products when no filter was provided', async () => { + const {body} = await client.get('/products/count').expect(200); + expect(body).to.deepEqual({count: 2}); + }); + + it('supports `where` query param', async () => { + const {body} = await client + .get('/products/count') + .query({'where[name]': pen.name}) + .expect(200); + expect(body).to.deepEqual({count: 1 /* pencil was omitted */}); + }); + }); + + describe('updateAll', () => { + beforeEach(seedData); + + it('updates all products when no filter was provided', async () => { + const {body} = await client + .patch('/products') + .send(PATCH_DATA) + .expect(200); + expect(body).to.deepEqual({count: 2}); + + const stored = await repo.find(); + expect(toJSON(stored)).to.deepEqual([ + {...toJSON(pen), ...PATCH_DATA}, + {...toJSON(pencil), ...PATCH_DATA}, + ]); + }); + + it('supports `where` query param', async () => { + const {body} = await client + .patch('/products') + .query({'where[name]': pen.name}) + .send(PATCH_DATA) + .expect(200); + expect(body).to.deepEqual({count: 1}); + + const stored = await repo.find(); + expect(toJSON(stored)).to.deepEqual([ + {...toJSON(pen), ...PATCH_DATA}, + {...toJSON(pencil) /* pencil was not patched */}, + ]); + }); + }); + + describe('updateById', () => { + beforeEach(seedData); + + it('updates model with the given id', async () => { + await client + .patch(`/products/${pen.id}`) + .send(PATCH_DATA) + .expect(204); + + const stored = await repo.find(); + expect(toJSON(stored)).to.deepEqual([ + {...toJSON(pen), ...PATCH_DATA}, + {...toJSON(pencil) /* pencil was not patched */}, + ]); + }); + + // TODO(bajtos) to fully verify this functionality, we should create + // a new test suite that will configure a PK with a different name + // and type, e.g. `pk: string` instead of `id: number`. + it('uses correct schema for the id parameter', async () => { + const spec = app.restServer.getApiSpec(); + const findByIdOp = spec.paths['/products/{id}'].patch; + expect(findByIdOp).to.containDeep({ + parameters: [ + { + name: 'id', + in: 'path', + schema: {type: 'number'}, + }, + ], + }); + }); + }); + + describe('deleteById', () => { + beforeEach(seedData); + + it('deletes model with the given id', async () => { + await client.del(`/products/${pen.id}`).expect(204); + + const stored = await repo.find(); + expect(toJSON(stored)).to.deepEqual( + toJSON([ + /* pen was deleted */ + pencil, + ]), + ); + }); + + // TODO(bajtos) to fully verify this functionality, we should create + // a new test suite that will configure a PK with a different name + // and type, e.g. `pk: string` instead of `id: number`. + it('uses correct schema for the id parameter', async () => { + const spec = app.restServer.getApiSpec(); + const findByIdOp = spec.paths['/products/{id}']['delete']; + expect(findByIdOp).to.containDeep({ + parameters: [ + { + name: 'id', + in: 'path', + schema: {type: 'number'}, + }, + ], + }); + }); + }); + + async function setupTestScenario() { + const db = new juggler.DataSource({connector: 'memory'}); + repo = new DefaultCrudRepository( + Product, + db, + ); + + const CrudRestController = defineCrudRestController< + Product, + typeof Product.prototype.id, + 'id' + >(Product, {basePath: '/products'}); + + class ProductController extends CrudRestController { + constructor() { + super(repo); + } + } + + app = new RestApplication({rest: givenHttpServerConfig()}); + app.controller(ProductController); + + await app.start(); + client = createRestAppClient(app); + } + + async function stopTheApp() { + await app.stop(); + } + + async function cleanDatabase() { + await repo.deleteAll(); + // Prevent accidental access to model instances created by previous tests + (pen as unknown) = undefined; + (pencil as unknown) = undefined; + } + + async function seedData() { + // It's important to make these calls in series, to ensure that "pen" + // comes first when the results are sorted by `id` (the default order). + pen = await repo.create({name: 'pen'}); + pencil = await repo.create({name: 'pencil'}); + } +}); diff --git a/packages/rest-crud/src/crud-rest.controller.ts b/packages/rest-crud/src/crud-rest.controller.ts new file mode 100644 index 000000000000..19ad0b3841f0 --- /dev/null +++ b/packages/rest-crud/src/crud-rest.controller.ts @@ -0,0 +1,320 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/crud-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Count, + CountSchema, + DataObject, + Entity, + EntityCrudRepository, + Filter, + Where, +} from '@loopback/repository'; +import { + api, + del, + get, + getFilterSchemaFor, + getJsonSchema, + getModelSchemaRef, + getWhereSchemaFor, + JsonSchemaOptions, + jsonToSchemaObject, + MediaTypeObject, + param, + ParameterObject, + patch, + post, + requestBody, + ResponsesObject, + SchemaObject, +} from '@loopback/rest'; + +// Ideally, this file should simply `export class CrudRestController<...>{}` +// Unfortunately, that's not possible for several reasons. +// +// First of all, to correctly decorate methods and define schemas for request +// and response bodies, we need to know the target model which will be used by +// the controller. As a result, this file has to export a function that will +// create a constructor class specific to the given model. +// +// Secondly, TypeScript does not allow decorators to be used in class +// expressions - see https://github.com/microsoft/TypeScript/issues/7342. +// As a result, we cannot write implement the factory as `return class ...`, +// but have to define the class as an internal type and return the controller +// constructor in a new statement. +// Because the controller class is an internal type scoped to the body of the +// factory function, we cannot use it to describe the return type. We must +// explicitly provide the return type. +// +// To work around those issues, we use the following design: +// - The interface `CrudRestController` describes controller methods (members) +// - The type `CrudRestControllerCtor` describes the class constructor. +// - `defineCrudRestController` returns `CrudRestControllerCtor` type. + +/** + * This interface describes prototype members of the controller class + * returned by `defineCrudRestController`. + */ +export interface CrudRestController< + T extends Entity, + IdType, + IdName extends keyof T, + Relations extends object = {} +> { + /** + * The backing repository used to access & modify model data. + */ + readonly repository: EntityCrudRepository; + + /** + * Implementation of the endpoint `POST /`. + * @param data Model data + */ + create(data: Omit): Promise; +} + +/** + * Constructor of the controller class returned by `defineCrudRestController`. + */ +export interface CrudRestControllerCtor< + T extends Entity, + IdType, + IdName extends keyof T, + Relations extends object = {} +> { + new ( + repository: EntityCrudRepository, + ): CrudRestController; +} + +/** + * Options to configure different aspects of a CRUD REST Controller. + */ +export interface CrudRestControllerOptions { + /** + * The base path where to "mount" the controller. + */ + basePath: string; +} + +/** + * Create (define) a CRUD Controller class for the given model. + * + * Example usage: + * + * ```ts + * const CrudRestController = defineCrudRestController< + * Product, + * typeof Product.prototype.id, + * 'id' + * >(Product, {basePath: '/products'}); + * + * class ProductController extends CrudRestController { + * constructor() { + * super(repo); + * } + * } + * + * app.controller(ProductController); + * ``` + * + * @param modelCtor A model class, e.g. `Product`. + * @param options Configuration options, e.g. `{basePath: '/products'}`. + */ +export function defineCrudRestController< + T extends Entity, + IdType, + IdName extends keyof T, + Relations extends object = {} +>( + modelCtor: typeof Entity & {prototype: T & {[key in IdName]: IdType}}, + options: CrudRestControllerOptions, +): CrudRestControllerCtor { + const modelName = modelCtor.name; + const idPathParam: ParameterObject = { + name: 'id', + in: 'path', + schema: getIdSchema(modelCtor), + }; + + @api({basePath: options.basePath, paths: {}}) + class CrudRestControllerImpl + implements CrudRestController { + constructor( + public readonly repository: EntityCrudRepository, + ) {} + + @post('/', { + ...response.model(200, `${modelName} instance created`, modelCtor), + }) + async create( + @body(modelCtor, {exclude: modelCtor.getIdProperties() as (keyof T)[]}) + data: Omit, + ): Promise { + return this.repository.create( + // FIXME(bajtos) Improve repository API to support this use case + // with no explicit type-casts required + data as DataObject, + ); + } + + @get('/', { + ...response.array(200, `Array of ${modelName} instances`, modelCtor, { + includeRelations: true, + }), + }) + async find( + @param.query.object('filter', getFilterSchemaFor(modelCtor)) + filter?: Filter, + ): Promise<(T & Relations)[]> { + return this.repository.find(filter); + } + + @get('/{id}', { + ...response.model(200, `${modelName} instance`, modelCtor, { + includeRelations: true, + }), + }) + async findById( + @param(idPathParam) id: IdType, + @param.query.object('filter', getFilterSchemaFor(modelCtor)) + filter?: Filter, + ): Promise { + return this.repository.findById(id, filter); + } + + @get('/count', { + ...response(200, `${modelName} count`, {schema: CountSchema}), + }) + async count( + @param.query.object('where', getWhereSchemaFor(modelCtor)) + where?: Where, + ): Promise { + return this.repository.count(where); + } + + @patch('/', { + ...response(200, `Count of ${modelName} models updated`, { + schema: CountSchema, + }), + }) + async updateAll( + @body(modelCtor, {partial: true}) data: Partial, + @param.query.object('where', getWhereSchemaFor(modelCtor)) + where?: Where, + ): Promise { + return this.repository.updateAll( + // FIXME(bajtos) Improve repository API to support this use case + // with no explicit type-casts required + data as DataObject, + where, + ); + } + + @patch('/{id}', { + responses: { + '204': {description: `${modelName} was updated`}, + }, + }) + async updateById( + @param(idPathParam) id: IdType, + @body(modelCtor, {partial: true}) data: Partial, + ): Promise { + await this.repository.updateById( + id, + // FIXME(bajtos) Improve repository API to support this use case + // with no explicit type-casts required + data as DataObject, + ); + } + + @del('/{id}', { + responses: { + '204': {description: `${modelName} was deleted`}, + }, + }) + async deleteById(@param(idPathParam) id: IdType): Promise { + await this.repository.deleteById(id); + } + } + + // See https://github.com/microsoft/TypeScript/issues/14607 + return CrudRestControllerImpl; +} + +function getIdSchema( + modelCtor: typeof Entity & {prototype: T}, +): SchemaObject { + const idProp = modelCtor.getIdProperties()[0]; + const modelSchema = jsonToSchemaObject( + getJsonSchema(modelCtor), + ) as SchemaObject; + return (modelSchema.properties || {})[idProp] as SchemaObject; +} + +// Temporary implementation of a short-hand version of `@requestBody` +// See https://github.com/strongloop/loopback-next/issues/3493 +function body( + modelCtor: Function & {prototype: T}, + options?: JsonSchemaOptions, +) { + return requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(modelCtor, options), + }, + }, + }); +} + +// Temporary workaround for a missing `@response` decorator +// See https://github.com/strongloop/loopback-next/issues/1672 +// Please note this is just a workaround, the real helper should be implemented +// as a decorator that contributes OpenAPI metadata in a way that allows +// `@post` to merge the responses with the metadata provided at operation level +function response( + statusCode: number, + description: string, + payload: MediaTypeObject, +): {responses: ResponsesObject} { + return { + responses: { + [`${statusCode}`]: { + description, + content: { + 'application/json': payload, + }, + }, + }, + }; +} + +namespace response { + export function model( + statusCode: number, + description: string, + modelCtor: Function & {prototype: T}, + options?: JsonSchemaOptions, + ) { + return response(statusCode, description, { + schema: getModelSchemaRef(modelCtor, options), + }); + } + + export function array( + statusCode: number, + description: string, + modelCtor: Function & {prototype: T}, + options?: JsonSchemaOptions, + ) { + return response(statusCode, description, { + schema: { + type: 'array', + items: getModelSchemaRef(modelCtor, options), + }, + }); + } +} diff --git a/packages/rest-crud/src/index.ts b/packages/rest-crud/src/index.ts new file mode 100644 index 000000000000..c1e8756c96b7 --- /dev/null +++ b/packages/rest-crud/src/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/crud-rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './crud-rest.controller'; diff --git a/packages/rest-crud/tsconfig.build.json b/packages/rest-crud/tsconfig.build.json new file mode 100644 index 000000000000..c7b8e49eaca5 --- /dev/null +++ b/packages/rest-crud/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"] +}