diff --git a/README.md b/README.md index 78274080..a8b3fc40 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ You can use routing-controllers with [express.js][1] or [koa.js][2]. + [Interceptor classes](#interceptor-classes) + [Global interceptors](#global-interceptors) * [Creating instances of classes from action params](#creating-instances-of-classes-from-action-params) + * [Controller inheritance](#controller-inheritance) * [Auto validating action params](#auto-validating-action-params) * [Using authorization features](#using-authorization-features) - [@Authorized decorator](#authorized-decorator) @@ -1236,6 +1237,33 @@ Learn more about class-transformer and how to handle more complex object constru This behaviour is enabled by default. If you want to disable it simply pass `classTransformer: false` to createExpressServer method. Alternatively you can disable transforming for [individual controllers or routes](#selectively-disable-requestresponse-transforming). +## Controller Inheritance +Often your application may need to have an option to inherit controller from another to reuse code and void duplication. +A good example of the use is the CRUD operations which can be hidden inside `AbstractBaseController` with the possibility to add new and overload methods, the template method pattern. + +```typescript +@Controller(`/product`) +class ProductController extends AbstractControllerTemplate {} +@Controller(`/category`) +class CategoryController extends AbstractControllerTemplate {} +abstract class AbstractControllerTemplate { + @Post() + public create() {} + + @Read() + public read() {} + + @Put() + public update() {} + + @Delete() + public delete() {} +} + +``` +https://en.wikipedia.org/wiki/Template_method_pattern + + ## Auto validating action params Sometimes parsing a json object into instance of some class is not enough. diff --git a/sample/sample17-controllers-inheritance/app.ts b/sample/sample17-controllers-inheritance/app.ts index cb75afc4..8f649840 100644 --- a/sample/sample17-controllers-inheritance/app.ts +++ b/sample/sample17-controllers-inheritance/app.ts @@ -8,4 +8,12 @@ useExpressServer(app, { }); app.listen(3001); // run express app -console.log("Express server is running on port 3001. Open http://localhost:3001/blogs/ or http://localhost:3002/posts/"); \ No newline at end of file +console.log( + "Possible GET endpoints you may see from a browser", + "http://localhost:3001/article", + "http://localhost:3001/article/1000", + "http://localhost:3001/product", + "http://localhost:3001/product/1000", + "http://localhost:3001/category", + "http://localhost:3001/category/1000", +); diff --git a/sample/sample17-controllers-inheritance/controllers/AbstractContollerTemplate.ts b/sample/sample17-controllers-inheritance/controllers/AbstractContollerTemplate.ts new file mode 100644 index 00000000..e8e84c7c --- /dev/null +++ b/sample/sample17-controllers-inheritance/controllers/AbstractContollerTemplate.ts @@ -0,0 +1,71 @@ +import {Res} from "../../../src/decorator/Res"; +import {Put} from "../../../src/decorator/Put"; +import {Post} from "../../../src/decorator/Post"; +import {Param} from "../../../src/decorator/Param"; +import {Get} from "../../../src/decorator/Get"; +import {Delete} from "../../../src/decorator/Delete"; +import {Body} from "../../../src/decorator/Body"; + +import {MockedRepository} from "../repository/MockedRepository"; +import {IInstance} from "../interface/IInstance"; + +/** + * @description the base controller class used by derivatives + */ +export abstract class AbstractControllerTemplate { + /** + * @description domain part of a system, also called object|entity|model + */ + protected domain: string; + protected repository: MockedRepository; + + @Post() + public async create( + @Body() payload: any, + @Res() res: any + ): Promise<{}> { + const item = await this.repository.create(payload); + + res.status(201); + res.location(`/${this.domain}/${item.id}`); + + return {}; + } + + @Put("/:id") + public async updated( + @Param("id") id: number, + @Body() payload: any, + @Res() res: any + ): Promise<{}> { + await this.repository.update(id, payload); + res.status(204); + + return {}; + } + + @Get("/:id") + public read( + @Param("id") id: number, + @Res() res: any + ): Promise { + return this.repository.find(id); + } + + @Get() + public readCollection( + @Res() res: any + ): Promise { + return this.repository.getCollection(); + } + + @Delete("/:id") + public async delete( + @Param("id") id: number, + @Res() res: any + ): Promise<{}> { + await this.repository.delete(id); + + return {}; + } +} diff --git a/sample/sample17-controllers-inheritance/controllers/ArticleController.ts b/sample/sample17-controllers-inheritance/controllers/ArticleController.ts new file mode 100644 index 00000000..e90f3fc8 --- /dev/null +++ b/sample/sample17-controllers-inheritance/controllers/ArticleController.ts @@ -0,0 +1,15 @@ +import {Controller} from "../../../src/decorator/Controller"; +import {AbstractControllerTemplate} from "./AbstractContollerTemplate"; +import {MockedRepository} from "../repository/MockedRepository"; + +const domain = "article"; + +@Controller(`/${domain}`) +export class ArticleController extends AbstractControllerTemplate { + protected constructor() { + super(); + + this.domain = domain; + this.repository = new MockedRepository(domain); + } +} diff --git a/sample/sample17-controllers-inheritance/controllers/BaseController.ts b/sample/sample17-controllers-inheritance/controllers/BaseController.ts deleted file mode 100644 index 158d15e1..00000000 --- a/sample/sample17-controllers-inheritance/controllers/BaseController.ts +++ /dev/null @@ -1,56 +0,0 @@ -import {Request} from "express"; -import {Get} from "../../../src/decorator/Get"; -import {Req} from "../../../src/index"; -import {Post} from "../../../src/decorator/Post"; -import {Put} from "../../../src/decorator/Put"; -import {Patch} from "../../../src/decorator/Patch"; -import {Delete} from "../../../src/decorator/Delete"; - -export class BaseControllerClass { - name: string; - constructor(name: string) { - this.name = name; - } - - @Get() - getAll() { - return [ - {id: 1, name: `First ${this.name}!`}, - {id: 2, name: `Second ${this.name}!`} - ]; - } - - @Get("/:id") - getOne() { - return {id: 1, name: `First ${this.name}!`}; - } - - @Post("") - post(@Req() request: Request) { - let entity = JSON.stringify(request.body); - return `${this.name} ${entity} !saved!`; - } - - @Put("/:id") - put(@Req() request: Request) { - return `${this.name} # ${request.params.id} has been putted!`; - } - - @Patch("/:id") - patch(@Req() request: Request) { - return `${this.name} # ${request.params.id} has been patched!`; - } - - @Delete("/:id") - remove(@Req() request: Request) { - return `${this.name} # ${request.params.id} has been removed!`; - } -} - -export function BaseController(name: string): { new(): BaseControllerClass } { - return class extends BaseControllerClass { - constructor() { - super(name); - } - }; -} \ No newline at end of file diff --git a/sample/sample17-controllers-inheritance/controllers/BlogController.ts b/sample/sample17-controllers-inheritance/controllers/BlogController.ts deleted file mode 100644 index a63b4184..00000000 --- a/sample/sample17-controllers-inheritance/controllers/BlogController.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {JsonController} from "../../../src/decorator/JsonController"; -import {BaseController, BaseControllerClass} from "./BaseController"; -import {Get} from "../../../src/decorator/Get"; - -@JsonController("/blogs") -export class BlogController extends BaseController("blog") { - constructor() { - super(); - } - - @Get() - getAll() { - return [ - {id: 1, name: `First ${this.name}!`}, - {id: 2, name: `Second ${this.name}!`}, - {id: 3, name: `Third ${this.name}!`} - ]; - } -} \ No newline at end of file diff --git a/sample/sample17-controllers-inheritance/controllers/CategoryController.ts b/sample/sample17-controllers-inheritance/controllers/CategoryController.ts new file mode 100644 index 00000000..222a3eb4 --- /dev/null +++ b/sample/sample17-controllers-inheritance/controllers/CategoryController.ts @@ -0,0 +1,15 @@ +import {Controller} from "../../../src/decorator/Controller"; +import {AbstractControllerTemplate} from "./AbstractContollerTemplate"; +import {MockedRepository} from "../repository/MockedRepository"; + +const domain = "category"; + +@Controller(`/${domain}`) +export class CategoryController extends AbstractControllerTemplate { + protected constructor() { + super(); + + this.domain = domain; + this.repository = new MockedRepository(domain); + } +} diff --git a/sample/sample17-controllers-inheritance/controllers/PostController.ts b/sample/sample17-controllers-inheritance/controllers/PostController.ts deleted file mode 100644 index 24929e48..00000000 --- a/sample/sample17-controllers-inheritance/controllers/PostController.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {JsonController} from "../../../src/decorator/JsonController"; -import {BaseController, BaseControllerClass} from "./BaseController"; - -@JsonController("/posts") -export class PostController extends BaseController("post") {} \ No newline at end of file diff --git a/sample/sample17-controllers-inheritance/controllers/ProductController.ts b/sample/sample17-controllers-inheritance/controllers/ProductController.ts new file mode 100644 index 00000000..b326c43c --- /dev/null +++ b/sample/sample17-controllers-inheritance/controllers/ProductController.ts @@ -0,0 +1,15 @@ +import {Controller} from "../../../src/decorator/Controller"; +import {AbstractControllerTemplate} from "./AbstractContollerTemplate"; +import {MockedRepository} from "../repository/MockedRepository"; + +const domain = "product"; + +@Controller(`/${domain}`) +export class ProductController extends AbstractControllerTemplate { + protected constructor() { + super(); + + this.domain = domain; + this.repository = new MockedRepository(domain); + } +} diff --git a/sample/sample17-controllers-inheritance/interface/IInstance.ts b/sample/sample17-controllers-inheritance/interface/IInstance.ts new file mode 100644 index 00000000..faaf150c --- /dev/null +++ b/sample/sample17-controllers-inheritance/interface/IInstance.ts @@ -0,0 +1,4 @@ +export interface IInstance { + id: number; + type: string; +} diff --git a/sample/sample17-controllers-inheritance/interface/IPayload.ts b/sample/sample17-controllers-inheritance/interface/IPayload.ts new file mode 100644 index 00000000..5c3491fe --- /dev/null +++ b/sample/sample17-controllers-inheritance/interface/IPayload.ts @@ -0,0 +1,3 @@ +export interface IPayload { + id: number; +} diff --git a/sample/sample17-controllers-inheritance/repository/MockedRepository.ts b/sample/sample17-controllers-inheritance/repository/MockedRepository.ts new file mode 100644 index 00000000..11282e8c --- /dev/null +++ b/sample/sample17-controllers-inheritance/repository/MockedRepository.ts @@ -0,0 +1,75 @@ +import {IInstance} from "../interface/IInstance"; +import {IPayload} from "../interface/IPayload"; + +export class MockedRepository { + protected domain: string; + + constructor(domain: string) { + this.domain = domain; + } + + /** + * @description Dummy method to return collection of items + */ + public getCollection(): Promise { + return Promise.resolve([ + { + id: 10020, + type: this.domain + }, + { + id: 10001, + type: this.domain + }, + { + id: 10002, + type: this.domain + }, + ]); + } + + /** + * @description Dummy method to create a new item in storage and return its instance + */ + public create(payload: IPayload): Promise { + return Promise.resolve( + { + id: 10000, + type: this.domain + } + ); + } + + /** + * @description Dummy method to find item in storage + */ + public find(id: number): Promise { + return Promise.resolve( + { + id: id, + type: this.domain + } + ); + } + + /** + * @description Dummy method to delete item in storage by id + */ + public delete(id: number): Promise { + return Promise.resolve(); + } + + /** + * @description Dummy method to update item in storage by id + */ + public update(id: number, payload: IPayload): Promise { + return Promise.resolve( + { + id: 10000, + type: this.domain + } + ); + } + +} + diff --git a/src/metadata-builder/MetadataBuilder.ts b/src/metadata-builder/MetadataBuilder.ts index 1fb18753..c3c33f2a 100644 --- a/src/metadata-builder/MetadataBuilder.ts +++ b/src/metadata-builder/MetadataBuilder.ts @@ -24,7 +24,7 @@ export class MetadataBuilder { /** * Builds controller metadata from a registered controller metadata args. */ - buildControllerMetadata(classes?: Function[]) { + buildControllerMetadata(classes?: Function[]): ControllerMetadata[] { return this.createControllers(classes); } diff --git a/test/unit/controller-inheritance.spec.ts b/test/unit/controller-inheritance.spec.ts new file mode 100644 index 00000000..ea5e4d4a --- /dev/null +++ b/test/unit/controller-inheritance.spec.ts @@ -0,0 +1,114 @@ +import "reflect-metadata"; +import {MetadataBuilder} from "../../src/metadata-builder/MetadataBuilder"; +import {Controller, getMetadataArgsStorage, Post} from "../../src"; + +const expect = require("chakram").expect; + +describe("controller inheritance", () => { + + it("should build empty meta for empty set", () => { + // Reset storage + getMetadataArgsStorage().reset(); + const metadataBuilder = new MetadataBuilder({}); + const meta = metadataBuilder.buildControllerMetadata([]); + + expect(meta.length).to.be.eq(0); + }); + + it("should build meta if the only derivative controller is given", () => { + // Reset storage + getMetadataArgsStorage().reset(); + + // Persist storage from decorators + abstract class AbstractControllerTemplate { + @Post() + public create() {} + } + + @Controller(`/derivative`) + class DerivativeController extends AbstractControllerTemplate {} + + @Controller(`/autonomous`) + class AutonomousController { + @Post() + public create() {} + } + + // Build controllers + const metadataBuilder = new MetadataBuilder({}); + const meta = metadataBuilder.buildControllerMetadata([ + DerivativeController, + ]); + + expect(meta.length).to.be.eq(1); + expect(meta[0].route).to.be.eq("/derivative"); + expect(meta[0].actions.length).to.be.eq(1); + + expect(meta[0].actions[0].method).to.be.eq("create"); + expect(meta[0].actions[0].type).to.be.eq("post"); + }); + + it("should build meta if the only autonomous controller is given", () => { + getMetadataArgsStorage().reset(); + + // Persist storage from decorators + abstract class AbstractControllerTemplate { + @Post() + public create() {} + } + + @Controller(`/derivative`) + class DerivativeController extends AbstractControllerTemplate {} + + @Controller(`/autonomous`) + class AutonomousController { + @Post() + public create() {} + } + + // Build controllers + const metadataBuilder = new MetadataBuilder({}); + const meta = metadataBuilder.buildControllerMetadata([ + AutonomousController, + ]); + + expect(meta.length).to.be.eq(1); + expect(meta[0].route).to.be.eq("/autonomous"); + expect(meta[0].actions.length).to.be.eq(1); + + expect(meta[0].actions[0].method).to.be.eq("create"); + expect(meta[0].actions[0].type).to.be.eq("post"); + }); + + it("should build meta both when autonomous and derivative controllers are given", () => { + getMetadataArgsStorage().reset(); + + // Persist storage from decorators + abstract class AbstractControllerTemplate { + @Post() + public create() {} + } + + @Controller(`/derivative`) + class DerivativeController extends AbstractControllerTemplate {} + + @Controller(`/autonomous`) + class AutonomousController { + @Post() + public create() {} + } + + // Build controllers + const metadataBuilder = new MetadataBuilder({}); + const meta = metadataBuilder.buildControllerMetadata(); + + expect(meta.length).to.be.eq(2); + expect(meta[0].actions.length).to.be.eq(1); + expect(meta[1].actions.length).to.be.eq(1); + + expect(meta[0].actions[0].method).to.be.eq("create"); + expect(meta[0].actions[0].type).to.be.eq("post"); + expect(meta[1].actions[0].method).to.be.eq("create"); + expect(meta[1].actions[0].type).to.be.eq("post"); + }); +});