├── .eslintrc.js ├── .github └── workflows │ └── release.yml ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── src ├── enums.ts ├── helper.ts ├── index.ts ├── methodset.controller.ts ├── query.ts └── types.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/eslint-recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | 'plugin:prettier/recommended', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | ignorePatterns: ['.eslintrc.js'], 20 | rules: { 21 | '@typescript-eslint/interface-name-prefix': 'off', 22 | '@typescript-eslint/explicit-function-return-type': 'off', 23 | '@typescript-eslint/explicit-module-boundary-types': 'off', 24 | '@typescript-eslint/no-explicit-any': 'off', 25 | '@typescript-eslint/ban-types': 'off', 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - production 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: 12 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Release 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | run: npx semantic-release 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nestjs-methodset 2 | 3 | **MethodSet** is an abstract controller that when extended will provide: 4 | - All the required `GET`/`DELETE`/`POST`/`UPDATE` endpoints for the TypeORM repository. 5 | - List endpoint contains: pagination, order, search, filter options. 6 | - filter options contains: `=`, `gt`, `gte`, `lt`, `lte` 7 | 8 | **nestjs-methodSet** is influenced by **ViewSet** in Django Rest Framework 9 | 10 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 11 | 12 | # Installation 13 | 14 | ```bash 15 | npm i nestjs-methodset 16 | ``` 17 | 18 | in `main.ts` add following to the app: 19 | 20 | ```typescript 21 | import { ValidationPipe } from '@nestjs/common'; 22 | ... 23 | app.useGlobalPipes(new ValidationPipe({ transform: true })); 24 | ``` 25 | 26 | # Code 27 | 28 | - Simply extends `MethodSet` in your controller and you done. 29 | Make sure to have the `repository` in your controller dependencies! 30 | 31 | ```typescript 32 | import { MethodSet } from 'nestjs-methodset'; 33 | 34 | @Controller({ path: 'article' }) 35 | export class ArticleController extends MethodSet { 36 | constructor( 37 | @InjectRepository(ArticleEntity) 38 | protected readonly repository: Repository 39 | ) { 40 | super(); 41 | } 42 | } 43 | ``` 44 | 45 | - Now your API has the following implemented functions: 46 | - get: GET `/article/` 47 | - list: GET `/article` 48 | - post: POST `/article` 49 | - update: UPDATE `/article/` 50 | - delete: DELETE `/article/` 51 | 52 | # List URL Params: 53 | 54 | the list endpoint contains different options as following: 55 | 56 | - **page** & **pageSize**: `?&page=1&pageSize=3` 57 | - **orderBy** & **sortOrder**(default: DESC): `?orderBy=timestamp&sortOrder=ASC` 58 | - **search** : `?search=test` 59 | - Must specify search fields in the controller, ie: 60 | ```typescript 61 | searchFields: SearchFields = ['body', 'title']; 62 | ``` 63 | - **filter[]**: 64 | - separate conditions by `,` ie: `?filter=price__lte=10,total=2`, 65 | - Or can be written: `?filter[]=price__lte=10&filter[]=total=2` 66 | 67 | # All Filter Valid Options: 68 | 69 | - `=`, equals, ie: `?filter=price=10` 70 | - `__gt=`, greater than, ie: `filter=price__lte=10` 71 | - `__gte=` greater than or equal 72 | - `__lt=` less than 73 | - `__lte=` less than or equal 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-methodset", 3 | "version": "1.0.1", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/AmrAnwar/nestjs-methodset.git" 16 | }, 17 | "keywords": [ 18 | "nestjs", 19 | "typescript", 20 | "controller" 21 | ], 22 | "author": "Amro", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/AmrAnwar/nestjs-methodset/issues" 26 | }, 27 | "homepage": "https://github.com/AmrAnwar/nestjs-methodset#readme", 28 | "devDependencies": { 29 | "@nestjs/common": "^9.0.0", 30 | "@semantic-release/commit-analyzer": "^8.0.1", 31 | "@semantic-release/git": "^9.0.0", 32 | "@semantic-release/npm": "^7.1.3", 33 | "@semantic-release/release-notes-generator": "^9.0.3", 34 | "class-transformer": "^0.5.1", 35 | "class-validator": "^0.13.2", 36 | "typeorm": "^0.3.7", 37 | "typescript": "^4.8.4" 38 | }, 39 | "release": { 40 | "plugins": [ 41 | "@semantic-release/commit-analyzer", 42 | "@semantic-release/release-notes-generator", 43 | "@semantic-release/npm", 44 | "@semantic-release/git" 45 | ] 46 | }, 47 | "peerDependencies": { 48 | "@nestjs/common": "^9.0.0", 49 | "class-validator": "^0.13.2", 50 | "class-transformer": "^0.5.1", 51 | "typeorm": "^0.3.7" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/enums.ts: -------------------------------------------------------------------------------- 1 | export enum SortOrder { 2 | ASC = 'ASC', 3 | DESC = 'DESC', 4 | } 5 | export enum FilterType { 6 | eq = 'eq', 7 | gt = 'gt', 8 | gte = 'gte', 9 | lt = 'lt', 10 | lte = 'lte', 11 | } 12 | -------------------------------------------------------------------------------- /src/helper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FindOptionsWhere, 3 | LessThan, 4 | LessThanOrEqual, 5 | MoreThan, 6 | MoreThanOrEqual, 7 | } from 'typeorm'; 8 | import { FilterType } from './enums'; 9 | import { FilterObj } from './types'; 10 | 11 | const isNumeric = (num: string) => !Number.isNaN(Number(num.trim())); 12 | 13 | export const filterObjToTypeORM = ( 14 | filterList: FilterObj[] 15 | ): FindOptionsWhere => { 16 | const filterWhere: FindOptionsWhere = {}; 17 | filterList.forEach((filter) => { 18 | if (filter.type == FilterType.eq) 19 | filterWhere[filter.field] = filter.value; 20 | else if (filter.type == FilterType.gt) 21 | filterWhere[filter.field] = MoreThan(filter.value); 22 | else if (filter.type == FilterType.gte) 23 | filterWhere[filter.field] = MoreThanOrEqual(filter.value); 24 | else if (filter.type == FilterType.lt) 25 | filterWhere[filter.field] = LessThan(filter.value); 26 | else if (filter.type == FilterType.lte) 27 | filterWhere[filter.field] = LessThanOrEqual(filter.value); 28 | }); 29 | return filterWhere; 30 | }; 31 | 32 | export const filterQueryObjMapper = (value: string | string[]): FilterObj[] => { 33 | const filterArray = Array.isArray(value) ? value : value.split(','); 34 | return filterArray.map((filterValue) => { 35 | if (!filterValue.includes('=')) throw Error("Filter must contain '='"); 36 | const [key, value] = filterValue.split('='); 37 | if ( 38 | key.endsWith(`__${FilterType.gt}`) || 39 | key.endsWith(`__${FilterType.lt}`) 40 | ) { 41 | return { 42 | type: key.slice(-2) as FilterType, 43 | value: Number(value), 44 | field: key.slice(0, -4), 45 | }; 46 | } else if ( 47 | key.endsWith(`__${FilterType.gte}`) || 48 | key.endsWith(`__${FilterType.lte}`) 49 | ) { 50 | return { 51 | type: key.slice(-3) as FilterType, 52 | value: Number(value), 53 | field: key.slice(0, -5), 54 | }; 55 | } else { 56 | return { 57 | type: FilterType.eq, 58 | value: isNumeric(value) ? Number(value) : value, 59 | field: key, 60 | }; 61 | } 62 | }); 63 | }; 64 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './methodset.controller'; 2 | export * from './types'; 3 | export * from './enums'; 4 | export * from './query'; -------------------------------------------------------------------------------- /src/methodset.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Delete, 4 | Get, 5 | Param, 6 | ParseIntPipe, 7 | Patch, 8 | Post, 9 | Query, 10 | } from '@nestjs/common'; 11 | import { 12 | BaseEntity, 13 | DeepPartial, 14 | FindManyOptions, 15 | FindOptionsWhere, 16 | ILike, 17 | Repository, 18 | } from 'typeorm'; 19 | import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; 20 | import { filterObjToTypeORM } from './helper'; 21 | import { ListFilter } from './query'; 22 | import { SearchFields } from './types'; 23 | 24 | export abstract class MethodSet { 25 | protected abstract readonly repository: Repository; 26 | protected pagination = true; 27 | protected pageSize: number; 28 | protected searchFields: SearchFields; 29 | protected where: FindManyOptions; 30 | @Get(':id') 31 | async get(@Param('id', ParseIntPipe) id: number): Promise { 32 | return this.repository.findOneById(id); 33 | } 34 | 35 | @Get() 36 | async list(@Query() filter: ListFilter) { 37 | const queryBuilder = this.repository.createQueryBuilder('alias'); 38 | if (this.searchFields && filter.search) { 39 | const whereSearch: FindOptionsWhere = {}; 40 | this.searchFields.forEach( 41 | (field) => 42 | (whereSearch[`${field}` as string] = ILike( 43 | `%${filter.search}%` 44 | )) 45 | ); 46 | queryBuilder.andWhere(whereSearch); 47 | } 48 | if (this.where) { 49 | queryBuilder.andWhere(this.where); 50 | } 51 | if (filter.filter && filter.filter.length > 0) { 52 | const filterWhere = filterObjToTypeORM(filter.filter); 53 | queryBuilder.andWhere(filterWhere); 54 | } 55 | if (filter.orderBy) { 56 | queryBuilder.orderBy( 57 | `${queryBuilder.alias}.${filter.sortOrder}`, 58 | filter.sortOrder 59 | ); 60 | } 61 | if (this.pagination) { 62 | const pageSize = this.pageSize ?? filter.pageSize; 63 | queryBuilder.take(pageSize); 64 | queryBuilder.skip((filter.page - 1) * pageSize); 65 | } 66 | const [items, totalCount] = await queryBuilder.getManyAndCount(); 67 | return { items, totalCount }; 68 | } 69 | 70 | @Post() 71 | async post(@Body() dto: DeepPartial): Promise { 72 | return this.repository.save(dto); 73 | } 74 | 75 | @Delete() 76 | async delete(@Param('id', ParseIntPipe) id: number): Promise { 77 | this.repository.delete(id); 78 | } 79 | 80 | @Patch(':id') 81 | async update( 82 | @Param('id', ParseIntPipe) id: number, 83 | @Body() dto: QueryDeepPartialEntity 84 | ): Promise { 85 | this.repository.update(id, dto); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | import { Transform, Type } from 'class-transformer'; 2 | import { IsArray, IsEnum, IsNumber, IsOptional } from 'class-validator'; 3 | import { SortOrder } from './enums'; 4 | import { filterQueryObjMapper } from './helper'; 5 | import { FilterObj } from './types'; 6 | 7 | export class ListFilter { 8 | @Transform(({ value }) => Math.max(Number(value), 1)) 9 | @IsNumber() 10 | @IsOptional() 11 | public page = 1; 12 | 13 | @Transform(({ value }) => Math.max(Number(value), 1)) 14 | @IsNumber() 15 | @IsOptional() 16 | public pageSize = 10; 17 | 18 | @IsOptional() 19 | public orderBy?: string; 20 | 21 | @IsEnum(SortOrder) 22 | @IsOptional() 23 | public sortOrder?: SortOrder = SortOrder.DESC; 24 | 25 | @IsOptional() 26 | public search?: string; 27 | 28 | @IsOptional() 29 | @IsArray() 30 | @Type(() => String) 31 | @Transform(({ value }) => filterQueryObjMapper(value)) 32 | filter?: FilterObj[]; 33 | } 34 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { FilterType } from "./enums"; 2 | 3 | export type FilterObj = { 4 | type: FilterType; 5 | value: string | number; 6 | field: string; 7 | }; 8 | 9 | export type SearchFields = Extract[]; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | --------------------------------------------------------------------------------