Skip to content

Commit 09ae329

Browse files
committed
[feature] add pagination, validation, and response middleware
1 parent 8dd6ef0 commit 09ae329

22 files changed

+286
-122
lines changed

.env.example

+2
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ DB_DATABASE=dev_db
1212
DB_HOST=postgres
1313
DB_DIALECT=postgres
1414
DB_PORT=5432
15+
16+
SNAKE_CASE=true

.github/workflows/test.yml

+23-22
Original file line numberDiff line numberDiff line change
@@ -43,29 +43,30 @@ jobs:
4343
- name: Setup Database
4444
run: yarn db:setup
4545
env:
46-
DB_USERNAME: user
47-
DB_PASSWORD: password
48-
DB_DATABASE: testdb
49-
DB_HOST: localhost
50-
DB_DIALECT: postgres
51-
DB_PORT: 5432
52-
NODE_ENV: test
53-
LOG_DIR: logs/
54-
LOG_FORMAT: dev
55-
LOG_LEVEL: debug
56-
PORT: 3000
46+
DB_USERNAME: user
47+
DB_PASSWORD: password
48+
DB_DATABASE: testdb
49+
DB_HOST: localhost
50+
DB_DIALECT: postgres
51+
DB_PORT: 5432
52+
NODE_ENV: test
53+
LOG_DIR: logs/
54+
LOG_FORMAT: dev
55+
LOG_LEVEL: debug
56+
PORT: 3000
5757

5858
- name: Run tests
5959
run: yarn test-jest
6060
env:
61-
DB_USERNAME: user
62-
DB_PASSWORD: password
63-
DB_DATABASE: testdb
64-
DB_HOST: localhost
65-
DB_DIALECT: postgres
66-
DB_PORT: 5432
67-
NODE_ENV: test
68-
LOG_DIR: logs/
69-
LOG_FORMAT: dev
70-
LOG_LEVEL: debug
71-
PORT: 3000
61+
DB_USERNAME: user
62+
DB_PASSWORD: password
63+
DB_DATABASE: testdb
64+
DB_HOST: localhost
65+
DB_DIALECT: postgres
66+
DB_PORT: 5432
67+
NODE_ENV: test
68+
LOG_DIR: logs/
69+
LOG_FORMAT: dev
70+
LOG_LEVEL: debug
71+
PORT: 3000
72+
SNAKE_CASE: true

package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"dropdb": "docker exec -it postgres14 dropdb --username=root test_db",
1717
"test-jest": "jest --ci --passWithNoTests --runInBand",
1818
"cleanup": "docker stop postgres14 && docker rm postgres14",
19-
"test": "yarn postgres && yarn createdb && cross-env NODE_ENV=test yarn db:setup && yarn test-jest; yarn cleanup"
19+
"test": "yarn postgres && yarn createdb && cross-env NODE_ENV=test yarn db:setup && yarn test-jest; yarn cleanup",
20+
"build": "tsc && tsc-alias"
2021
},
2122
"dependencies": {
2223
"body-parser": "^1.20.2",
@@ -34,6 +35,7 @@
3435
"routing-controllers": "^0.10.4",
3536
"sequelize": "^6.33.0",
3637
"sequelize-typescript": "^2.1.5",
38+
"tsc-alias": "^1.8.8",
3739
"typedi": "^0.10.0",
3840
"winston": "^3.11.0",
3941
"winston-daily-rotate-file": "^4.7.1"
@@ -45,12 +47,12 @@
4547
"@types/multer": "^1.4.9",
4648
"@typescript-eslint/eslint-plugin": "^6.8.0",
4749
"@typescript-eslint/parser": "^6.8.0",
48-
"jest": "^29.7.0",
4950
"cross-env": "^7.0.3",
5051
"eslint-config-prettier": "^9.0.0",
5152
"eslint-loader": "^4.0.2",
5253
"eslint-plugin-import": "^2.28.1",
5354
"eslint-plugin-prettier": "^5.0.1",
55+
"jest": "^29.7.0",
5456
"nodemon": "^3.0.1",
5557
"prettier": "^3.0.3",
5658
"sequelize-cli": "^6.6.1",
+38-38
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,23 @@
11
import { API_RES, StatusCodeEnum } from "api/enum/api.enum";
22
import { ApiResponse } from "api/types/response.interface";
3-
import { HttpError } from "routing-controllers";
43
import { Response } from "express";
54
import BaseEntity from "api/models/entities/types/Base.entity";
6-
import { SnakeCaseObj } from "@lib/validation/types";
5+
import { env } from "@env";
76

87
export class BaseController {
98
protected code: StatusCodeEnum = StatusCodeEnum.OK;
109
protected data: unknown;
11-
protected message: string = "Success.";
10+
protected message: string = "Success";
1211
protected typeRes: API_RES = API_RES.JSON;
13-
protected exception: HttpError;
12+
protected enableSnakeCase: boolean = env.config.enableSnakeCase;
1413

1514
public setCode(code: StatusCodeEnum): this {
1615
this.code = code;
1716
return this;
1817
}
1918

20-
public setData<T>(data: T): this {
21-
if (data instanceof Array) {
22-
this.data = data.map((item) => {
23-
return this.isBaseEntity(item) ? this.getSnakeEntity(item) : item;
24-
});
25-
return this;
26-
}
27-
if (this.isBaseEntity<T>(data)) {
28-
this.data = this.getSnakeEntity(data);
29-
return this;
30-
}
31-
this.data = data;
19+
public setData<T>(data: T | BaseEntity<T>[]): this {
20+
this.data = this.transformData(data);
3221
return this;
3322
}
3423

@@ -37,49 +26,60 @@ export class BaseController {
3726
return this;
3827
}
3928

29+
public setTypeRes(type: API_RES): this {
30+
this.typeRes = type;
31+
return this;
32+
}
33+
4034
protected getResponse<T>(res: Response, status: boolean): Response {
4135
const result: ApiResponse<T> = {
42-
status: status ? true : false,
36+
status,
4337
code: this.code,
4438
data: this.data as T,
4539
message: this.message,
4640
};
4741

48-
if (this.typeRes === API_RES.JSON) {
49-
return res.status(this.code).json(result);
42+
switch (this.typeRes) {
43+
case API_RES.JSON:
44+
return res.status(this.code).json(result);
45+
case API_RES.SEND:
46+
return res.status(this.code).send(result);
47+
default:
48+
throw new Error(`Unsupported response type: ${this.typeRes}`);
5049
}
51-
52-
if (this.typeRes === API_RES.SEND) {
53-
return res.status(this.code).send(result);
54-
}
55-
56-
// Default to JSON response if type is not recognized
57-
return res.status(this.code).json(result);
5850
}
5951

6052
public responseSuccess<T>(data: T, message: string, res: Response): Response {
61-
this.setCode(StatusCodeEnum.OK);
62-
this.setData(data);
63-
this.setMessage(message);
64-
return this.getResponse(res, true);
53+
return this.setCode(StatusCodeEnum.OK)
54+
.setData(data)
55+
.setMessage(message)
56+
.getResponse<T>(res, true);
6557
}
6658

67-
public responseErrors(
59+
public responseError(
6860
res: Response,
6961
message: string = "An error occurred",
7062
statusCode: StatusCodeEnum = StatusCodeEnum.INTERNAL_SERVER_ERROR
7163
): Response {
72-
this.setCode(statusCode);
73-
this.setData(null);
74-
this.setMessage(message);
75-
return this.getResponse<null>(res, false);
64+
return this.setCode(statusCode)
65+
.setData(null)
66+
.setMessage(message)
67+
.getResponse<null>(res, false);
7668
}
7769

78-
private getSnakeEntity<T>(data: BaseEntity<T>): SnakeCaseObj<T> {
79-
return data.toSnake();
70+
private transformData<T>(data: T | BaseEntity<T>[]): unknown {
71+
if (!this.enableSnakeCase) return data;
72+
73+
if (Array.isArray(data)) {
74+
return data.map((item) =>
75+
this.isBaseEntity(item) ? item.toSnake() : item
76+
);
77+
}
78+
79+
return this.isBaseEntity(data) ? data.toSnake() : data;
8080
}
8181

8282
private isBaseEntity<T>(object: unknown): object is BaseEntity<T> {
83-
return (object as BaseEntity<T>).toSnake !== undefined;
83+
return (object as BaseEntity<T>)?.toSnake !== undefined;
8484
}
8585
}

src/api/controllers/users.controller.ts

+8-15
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ import { BodyToCamelCase } from "@decorators/SnakeToCamel.decorator";
55
import { CreateUserDTO } from "api/validation/DTO/user.dto";
66
import validationMiddleware from "middlewares/validation.middleware";
77
import { CamelCaseObj } from "@lib/validation/types";
8-
import { logger } from "@lib/debug/logger";
9-
import { HttpException } from "api/errors/HttpException";
10-
import { StatusCodeEnum } from "api/enum/api.enum";
118

129
import { Request, Response } from "express";
1310
import {
@@ -19,6 +16,8 @@ import {
1916
UseBefore,
2017
} from "routing-controllers";
2118
import { Service } from "typedi";
19+
import { HandleErrors } from "@decorators/errorHandler.decorator";
20+
import { GetPagination, Pagination } from "@decorators/pagination.decorator";
2221

2322
@JsonController("/users")
2423
@Service()
@@ -28,34 +27,28 @@ class UsersController extends BaseController {
2827
}
2928

3029
@Get("/")
30+
@HandleErrors
3131
public async getUsers(
32+
@GetPagination() { skip, limit }: Pagination,
3233
@Req() _: Request,
3334
@Res() res: Response
3435
): Promise<Response> {
35-
const users = await this.usersService.getAllUsers();
36+
const users = await this.usersService.getAllUsers({ skip, limit });
3637

3738
return this.responseSuccess<Array<User>>(users, "Success", res);
3839
}
3940

4041
@Post("/")
4142
@UseBefore(validationMiddleware(CreateUserDTO))
43+
@HandleErrors
4244
public async createUser(
4345
@BodyToCamelCase() body: CamelCaseObj<CreateUserDTO>,
4446
@Req() _: Request,
4547
@Res() res: Response
4648
): Promise<Response> {
47-
try {
48-
const user = await this.usersService.createUser(body);
49+
const user = await this.usersService.createUser(body);
4950

50-
return this.responseSuccess<User>(user, "Success", res);
51-
} catch (error: unknown) {
52-
logger.error(error);
53-
54-
throw new HttpException(
55-
StatusCodeEnum.INTERNAL_SERVER_ERROR,
56-
"Something went wrong."
57-
);
58-
}
51+
return this.responseSuccess<User>(user, "Success", res);
5952
}
6053
}
6154

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { logger } from "@lib/debug/logger";
2+
import { StatusCodeEnum } from "api/enum/api.enum";
3+
import { HttpException } from "api/errors/HttpException";
4+
5+
export function HandleErrors<T>(
6+
target: T,
7+
propertyName: string,
8+
descriptor: PropertyDescriptor
9+
) {
10+
const originalMethod = descriptor.value;
11+
12+
if (!originalMethod) {
13+
throw new Error("Expected method not found on descriptor");
14+
}
15+
16+
descriptor.value = async function (...args: unknown[]) {
17+
try {
18+
return await originalMethod.apply(this as T, args);
19+
} catch (error: unknown) {
20+
if (error instanceof HttpException) {
21+
throw error;
22+
} else {
23+
logger.error(error);
24+
throw new HttpException(
25+
StatusCodeEnum.INTERNAL_SERVER_ERROR,
26+
"Something went wrong."
27+
);
28+
}
29+
}
30+
};
31+
}
+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { createParamDecorator, Action } from "routing-controllers";
2+
3+
export interface Pagination {
4+
skip: number;
5+
limit: number;
6+
sort?: Array<SortItem>;
7+
search?: Record<string, string>;
8+
}
9+
type SortItem = { field: string; by: "ASC" | "DESC" };
10+
type SearchItem = `${string}:${string}`;
11+
12+
export function GetPagination(options?: { required?: boolean }) {
13+
return createParamDecorator({
14+
required: options?.required ?? false,
15+
value: (action: Action): Pagination => {
16+
const query = action.request.query;
17+
const paginationParams: Pagination = {
18+
skip: parseInt(query.skip?.toString()) || 0,
19+
limit: parseInt(query.limit?.toString()) || 10,
20+
};
21+
22+
if (query.sort) {
23+
const sortArray = query.sort.toString().split(",");
24+
paginationParams.sort = sortArray.map((sortItem: string): SortItem => {
25+
const trimmedItem = sortItem.trim();
26+
return {
27+
field: trimmedItem.startsWith("-")
28+
? trimmedItem.slice(1)
29+
: trimmedItem,
30+
by: trimmedItem.startsWith("-") ? "ASC" : "DESC",
31+
};
32+
});
33+
}
34+
35+
if (query.search) {
36+
const searchArray = query.search.toString().split(",");
37+
paginationParams.search = {};
38+
searchArray.forEach((searchItem: SearchItem) => {
39+
const [field, value] = searchItem.split(":");
40+
if (field && value) {
41+
paginationParams.search[field.trim()] = value.trim();
42+
}
43+
});
44+
}
45+
46+
return paginationParams;
47+
},
48+
});
49+
}

src/api/errors/HttpException.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ export class HttpException<T> extends HttpError {
55
public data?: T;
66

77
constructor(status: number, message: string, data?: T) {
8-
super(status);
8+
super(status, message);
99
this.status = status;
1010
this.message = message;
1111
this.data = data;
12+
Object.setPrototypeOf(this, HttpException.prototype);
1213
}
1314
}

src/api/models/entities/User.entity.ts

+1-8
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,13 @@ import {
66
Table,
77
Default,
88
DataType,
9-
Model,
109
} from "sequelize-typescript";
1110
import BaseEntity from "./types/Base.entity";
12-
import { convertKeysToSnakeCase } from "@lib/validation/utils";
13-
import { SnakeCaseObj } from "@lib/validation/types";
1411

1512
@Table({
1613
tableName: "users",
1714
})
18-
export default class User extends Model<User> implements BaseEntity<User> {
15+
export default class User extends BaseEntity<User> {
1916
@PrimaryKey
2017
@AutoIncrement
2118
@Column(DataType.INTEGER)
@@ -49,8 +46,4 @@ export default class User extends Model<User> implements BaseEntity<User> {
4946

5047
@Column(DataType.DATE)
5148
updatedAt: Date;
52-
53-
toSnake() {
54-
return convertKeysToSnakeCase(this.get()) as SnakeCaseObj<this>;
55-
}
5649
}

0 commit comments

Comments
 (0)