Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add GET Single Heroes API with test, skip the unstable response format part #2

Merged
merged 9 commits into from
Jun 28, 2024
Empty file added logs/.gitkeep
Empty file.
367 changes: 363 additions & 4 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,24 @@
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"axios": "^1.7.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"uuid": "^10.0.0",
"winston": "^3.13.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/nock": "^11.1.0",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
Expand All @@ -41,6 +48,7 @@
"husky": "^9.0.11",
"jest": "^29.5.0",
"lint-staged": "^15.2.7",
"nock": "^13.5.4",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
Expand Down Expand Up @@ -69,6 +77,7 @@
"^#app/(.*$)": "<rootDir>/src/modules/app/$1",
"^#hero/(.*$)": "<rootDir>/src/modules/hero/$1",
"^#http/(.*$)": "<rootDir>/src/modules/http/$1",
"^#logger/(.*$)": "<rootDir>/src/modules/logger/$1",
"^#cores/(.*$)": "<rootDir>/src/cores/$1"
},
"moduleFileExtensions": [
Expand Down
1 change: 0 additions & 1 deletion src/cores/constants/index.ts

This file was deleted.

9 changes: 0 additions & 9 deletions src/cores/constants/satus.ts

This file was deleted.

15 changes: 15 additions & 0 deletions src/cores/decorators/local.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

import { InternalRequest } from '#cores/types';

/**
* @description Getting req specific information from req.locals.
*
* Reference: https://github.com/nestjs/nest/issues/913#issuecomment-408822021
*/
export const Local = createParamDecorator(
(key: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<InternalRequest>();
return key ? request.locals[key] : request.locals;
},
);
52 changes: 52 additions & 0 deletions src/cores/exceptions/general.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
HttpException,
} from '@nestjs/common';
import { Response } from 'express';

import { InternalRequest, CustomErrorCodes, ErrorResponse } from '#cores/types';
import { CustomLog } from '#logger/appLog.service';

/**
* @description AllExceptionsFilter is a filter to catch all exceptions and return a proper response.
* If error response interface is not determined, it will treat it as Internal Server Error.
*/
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly logger: CustomLog) {}

catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request: InternalRequest = ctx.getRequest<InternalRequest>();
const response: Response = ctx.getResponse<Response>();
const requestId: string = request.locals.requestId;

let statusCode: number = HttpStatus.INTERNAL_SERVER_ERROR;
let errorResponse: ErrorResponse = {
code: CustomErrorCodes.INTERNAL_SERVER_ERROR,
message: 'Internal Server Error',
requestId,
};

if (exception instanceof HttpException) {
const customException = exception.getResponse() as ErrorResponse;
statusCode = exception.getStatus();
errorResponse = {
...customException,
requestId,
};
}

this.logger.info(
requestId,
`Response, ${statusCode}, ${request.url}, ${JSON.stringify(
response.getHeaders(),
)}, Error: ${JSON.stringify(errorResponse)}`,
);

response.status(statusCode).json(errorResponse);
}
}
30 changes: 30 additions & 0 deletions src/cores/interceptors/requestId.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {
ExecutionContext,
CallHandler,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { InternalRequest } from '../types';

/**
* @description RequestIdInterceptor is a interceptor to help generate request id.
* It will be place at the beginning of the request and store the request id in the request locals.
*/
@Injectable()
export class RequestIdInterceptor implements NestInterceptor {
constructor() {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req: InternalRequest = context.switchToHttp().getRequest();

const headerName = 'X-Request-Id';
const headerId = req.get(headerName);
const requestId = headerId ? headerId : uuidv4();

req.locals = req.locals || {};
req.locals.requestId = requestId;
return next.handle();
}
}
58 changes: 58 additions & 0 deletions src/cores/interceptors/responseLog.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Response } from 'express';

import { InternalRequest } from '#cores/types';
import { CustomLog } from '#logger/appLog.service';

/**
* @description LogInterceptor is a interceptor to help track request and response logs.
* If endpoint is blacklisted, take k8s health check endpoint for example, it will not log the request and response.
*/
@Injectable()
export class LogInterceptor implements NestInterceptor {
private readonly blacklist: Array<string> = ['/health', '/metrics'];

constructor(private readonly logger: CustomLog) {}

private isBlacklisted(url: string): boolean {
return this.blacklist.some((blacklistedUrl) =>
url.includes(blacklistedUrl),
);
}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request: InternalRequest = context.switchToHttp().getRequest();
const requestId: string = request.locals.requestId;
const { method, url } = request;

// early return if endpoint is blacklisted
if (this.isBlacklisted(url)) return next.handle();

this.logger.log(
requestId,
`Request, ${method}, ${url}, ${JSON.stringify(
request.headers,
)}, ${JSON.stringify(request.body)}`,
);

return next.handle().pipe(
tap((data) => {
const response: Response = context.switchToHttp().getResponse();
const { statusCode } = response;
this.logger.log(
requestId,
`Response, ${statusCode}, ${url}, ${JSON.stringify(
response.getHeaders(),
)}, ${JSON.stringify(data)}`,
);
}),
);
}
}
16 changes: 16 additions & 0 deletions src/cores/types/errorResnpose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export enum CustomErrorCodes {
BAD_REQUEST = 40000,
UNAUTHORIZED = 40100,
FORBIDDEN = 40300,
NOT_FOUND = 40400,
INTERNAL_SERVER_ERROR = 50000,
THIRDPARTY_SERVER_ERROR = 50001,
THIRDPARTY_API_RESPONSE_MISMATCH = 50002,
SERVICE_UNAVAILABLE = 50300,
}

export interface ErrorResponse {
code: CustomErrorCodes;
message: string;
requestId: string;
}
2 changes: 2 additions & 0 deletions src/cores/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './errorResnpose';
export * from './internalReqeust';
22 changes: 22 additions & 0 deletions src/cores/types/internalReqeust.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Request } from 'express';

/**
* Nest official doesn't recommend to use res.locals, use req.locals instead.
*
* Reference: https://github.com/nestjs/nest/issues/913
*/
export interface InternalRequest extends Request {
locals: {
[key: string]: any;
requestId: string;
};
}

declare module 'express' {
export interface Request {
locals?: {
[key: string]: any;
requestId?: string;
};
}
}
5 changes: 4 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { NestFactory } from '@nestjs/core';

import { AppModule } from '#app/app.module';
import { SystemLog } from '#logger/systemLog.service';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule, {
logger: new SystemLog(),
});
await app.listen(3000);
}
bootstrap();
5 changes: 2 additions & 3 deletions src/modules/app/app.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { CustomErrorCodes } from '#src/cores/types';
import { Controller, Get, All, NotFoundException } from '@nestjs/common';

import { INTERNAL_STATUS_CODE } from '#cores/constants';

@Controller()
export class AppController {
constructor() {}
Expand All @@ -16,7 +15,7 @@ export class AppController {
const NOT_FOUND_MSG = 'Resource not found';

throw new NotFoundException({
code: INTERNAL_STATUS_CODE.NOT_FOUND,
code: CustomErrorCodes.NOT_FOUND,
message: NOT_FOUND_MSG,
});
}
Expand Down
16 changes: 14 additions & 2 deletions src/modules/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { Module } from '@nestjs/common';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';

import { AppController } from '#src/modules/app/app.controller';
import { AllExceptionsFilter } from '#cores/exceptions/general.exception';
import { RequestIdInterceptor } from '#cores/interceptors/requestId.interceptor';
import { LogInterceptor } from '#cores/interceptors/responseLog.interceptor';
import { AppController } from '#app/app.controller';
import { HeroModule } from '#hero/hero.module';
import { HeroController } from '#hero/hero.controller';
import { CustomLog } from '#logger/appLog.service';
import { LoggerModule } from '#logger/logger.module';

@Module({
imports: [LoggerModule, HeroModule],
controllers: [HeroController, AppController],
imports: [HeroModule],
providers: [
{ provide: APP_INTERCEPTOR, useClass: RequestIdInterceptor },
{ provide: APP_INTERCEPTOR, useClass: LogInterceptor },
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
CustomLog,
],
})
export class AppModule {}
7 changes: 7 additions & 0 deletions src/modules/hero/dto/hero.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { IsNumberString } from 'class-validator';

export interface Hero {
id: string;
name: string;
Expand All @@ -7,3 +9,8 @@ export interface Hero {
export interface GetHerosResponseDto {
heroes: Array<Hero>;
}

export class GetSingleHeroReqParams {
@IsNumberString()
id: string;
}
10 changes: 8 additions & 2 deletions src/modules/hero/hero.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Param } from '@nestjs/common';

import { HeroService } from '#hero/hero.service';
import { GetHerosResponseDto } from '#hero/dto';
import { GetHerosResponseDto, GetSingleHeroReqParams, Hero } from '#hero/dto';

@Controller()
export class HeroController {
Expand All @@ -19,4 +19,10 @@ export class HeroController {

return { heroes };
}

@Get('heroes/:id')
async getHeroById(@Param() params: GetSingleHeroReqParams): Promise<Hero> {
const { id } = params;
return this.heroService.findById(id);
}
}
4 changes: 4 additions & 0 deletions src/modules/hero/hero.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ export class HeroService {
async findAll(): Promise<Array<Hero>> {
return this.http.getHeroes();
}

async findById(id: string): Promise<Hero> {
return this.http.getHeroById(id);
}
}
Loading
Loading