NestJS-Module Nesting

jaegeunsong97·2024년 2월 22일
0

NestJS

목록 보기
33/37

🖊️Module Nesting

댓글 기능을 만들도록 하겠습니다. 댓글의 경우 Post 내부에 존재하기 때문에 posts 내부에 생성을 하도록 하겠습니다.

nest g resource -> comments -> REST API -> n
  • posts/comments/comments.controller.ts
@Controller('posts/:postId/comments')
export class CommentsController {
  constructor(private readonly commentsService: CommentsService) {}

  /**
   * 1) Entity 생성
   * author -> 작성자
   * comment -> 실제 댓글 내용
   * likeCount -> 좋아요 갯수
   * 
   * id -> PrimaryGeneratedColumn
   * createAt -> 생성일자
   * updatedAt -> 업데이트일자
   * 
   * 2) GET() pagination
   * 3) GET(':commentId') 특정 comment만 하나 가져오는 기능
   * 4) POST() 코멘트 생성하는 기능
   * 5) PATCH(':commentId') 특정 comment 업데이트 하는 기능
   * 6) DELETE(':commentId) 특정 comment 삭제하는 기능
   */
}

🖊️Comments Entiy

  • posts/comments/entity/comments.entity.ts
import { IsNumber, IsString } from "class-validator";
import { BaseModel } from "src/common/entity/base.entity";
import { PostsModel } from "src/posts/entity/posts.entity";
import { UsersModel } from "src/users/entity/users.entity";
import { Column, Entity, ManyToOne } from "typeorm";

@Entity()
export class CommentsModel extends BaseModel {
     
    @ManyToOne(() => UsersModel, (user) => user.postComments)
    author: UsersModel;

    @ManyToOne(() => PostsModel, (post) => post.comments)
    post: PostsModel;

    @Column()
    @IsString()
    comment: string;

    @Column({
      	default: 0
    })
    @IsNumber()
    likeCount: number;
}
  • posts.entity.ts
.
.
@OneToMany(() => CommentsModel, (comment) => comment.post)
comments: CommentsModel[];
  • users.entity.ts
.
.
@OneToMany(() => CommentsModel, (comment) => comment.author)
postComments: CommentsModel[];

app.module.ts에 등록을 하고 typeORM 사용을 위해 typeORM 등록을 하겠습니다.

  • app.module.ts
entities: [
    PostsModel,
    UsersModel,
    ImageModel,
    ChatsModel,
    MessagesModel,
    CommentsModel, // 등록
],
  • comments.module.ts
import { Module } from '@nestjs/common';
import { CommentsService } from './comments.service';
import { CommentsController } from './comments.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CommentsModel } from './entity/comments.entity';

@Module({
    imports: [
        TypeOrmModule.forFeature([
          	CommentsModel,
        ])
    ],
    controllers: [CommentsController],
    providers: [CommentsService],
})
export class CommentsModule {}

🖊️Paginate Comments API

  • comments/dto/paginate-comments.dto.ts
import { BasePaginationDto } from "src/common/dto/base-pagination.dto";

export class PaginateCommentsDto extends BasePaginationDto {}
  • comments.controller.ts
@Get()
getComments(
    @Param('postId', ParseIntPipe) postId: number,
    @Query() query: PaginateCommentsDto
) {
    return this.commentsService.paginateComments(
        query,
        postId,
    );
}
  • comments.service.ts
@Injectable()
export class CommentsService {

    constructor(
    	@InjectRepository(CommentsModel)
       	private readonly commentsRepository: Repository<CommentsModel>,
       	private readonly commonService: CommonService,
    ) {}

    paginateComments(
    	dto: PaginateCommentsDto,
     	postId: number,
    ) {
        return this.commonService.paginate(
            dto,
            this.commentsRepository,
            {
                where: {
                    post: {
                      	id: postId,
                    }
                }
            },
            `posts/${postId}/comments`,
        );
    }
}
  • comments.module.ts
@Module({
    imports: [
        TypeOrmModule.forFeature([
          	CommentsModel,
        ]),
        CommonModule, // 등록
    ],
    controllers: [CommentsController],
    providers: [CommentsService],
})
export class CommentsModule {}

🖊️ID 기반으로 하나의 Comment 가져오는 API

  • comments.controller.ts
@Get(':commentId')
getComment(
  	@Param('commentId', ParseIntPipe) commentId: number,
) {
  	return this.commentsService.getCommentById(commentId);
}
  • comments.service.ts
async getCommentById(id: number) {
    const comment = await this.commentsRepository.findOne({
        where: {
          	id,
        }
    });
    if (!comment) throw new BadRequestException(`id: ${id} Comment는 존재하지 않습니다. `);
  	return comment;
}

포스트맨으로 테스트를 하겠습니다.

{
    "message": "id: 1 Comment는 존재하지 않습니다. ",
    "error": "Bad Request",
    "statusCode": 400
}

🖊️Comment 생성 API

  • dto/create-comments.dto.ts
import { PickType } from "@nestjs/mapped-types";
import { CommentsModel } from "../entity/comments.entity";

export class CreateCommentsDto extends PickType(CommentsModel, [
     'comment' // comment 프로퍼티만 상속받기
]) {}
  • comments.controller.ts
@Post()
@UseGuards(AccessTokenGuard)
postComment(
    @Param('postId', ParseIntPipe) postId: number,
    @Body() body: CreateCommentsDto,
    @User() user: UsersModel
) {
    return this.commentsService.createComment(
        body, 
        postId, 
        user
    )
} 
  • comments.service.ts
async createComment(
    dto: CreateCommentsDto,
    postId: number,
    author: UsersModel // AccessToken에서 넘겨주면 UsersModel이 들어있음
) {
    return this.commentsRepository.save({
        ...dto,
        post: {
          	id: postId
        },
        author,
    })
}
  • comments.module.ts
@Module({
    imports: [
        TypeOrmModule.forFeature([
          	CommentsModel,
        ]),
        CommonModule,
        AuthModule,
        UsersModule,
    ],
    controllers: [CommentsController],
    providers: [CommentsService],
})
export class CommentsModule {}

포스트맨으로 테스트를 해보겠습니다. 로그인 후 토큰 값을 넣은 뒤에 요청합니다.

{
    "comment": "강의 너무 좋아요!!!",
    "post": {
        "id": 101
    },
    "author": {
        "id": 4,
        "updatedAt": "2024-02-18T02:33:34.030Z",
        "createdAt": "2024-02-18T02:33:34.030Z",
        "nickname": "codefactory123",
        "email": "codefactory123@codefactory.ai",
        "role": "USER"
    },
    "id": 1,
    "updatedAt": "2024-02-21T17:43:27.716Z",
    "createdAt": "2024-02-21T17:43:27.716Z",
    "likeCount": 0
}

101에 대한 댓글 페이지네이션을 요청하겠습니다.

{
    "data": [
        {
            "id": 1,
            "updatedAt": "2024-02-21T17:43:27.716Z",
            "createdAt": "2024-02-21T17:43:27.716Z",
            "comment": "강의 너무 좋아요!!!",
            "likeCount": 0
        }
    ],
    "cursor": {
        "after": null
    },
    "count": 1,
    "next": null
}

누가 작성했는지까지 포함하겠습니다.

  • comments.service.ts
paginateComments(
    dto: PaginateCommentsDto,
    postId: number,
) {
    return this.commonService.paginate(
      dto,
      this.commentsRepository,
      {
          where: {
              post: {
                	id: postId,
              }
          },
          relations: { // 추가
            	author: true
          }
      },
      `posts/${postId}/comments`,
    );
}

{
    "data": [
        {
            "id": 1,
            "updatedAt": "2024-02-21T17:43:27.716Z",
            "createdAt": "2024-02-21T17:43:27.716Z",
            "comment": "강의 너무 좋아요!!!",
            "likeCount": 0,
            "author": {
                "id": 4,
                "updatedAt": "2024-02-18T02:33:34.030Z",
                "createdAt": "2024-02-18T02:33:34.030Z",
                "nickname": "codefactory123",
                "email": "codefactory123@codefactory.ai",
                "role": "USER"
            }
        }
    ],
    "cursor": {
        "after": null
    },
    "count": 1,
    "next": null
}

이번에는 특정 Post의 comment정보를 가져오는 요청을 해보겠습니다.

{
    "id": 1,
    "updatedAt": "2024-02-21T17:43:27.716Z",
    "createdAt": "2024-02-21T17:43:27.716Z",
    "comment": "강의 너무 좋아요!!!",
    "likeCount": 0
}

해당 요청에도 override 기능을 추가해보겠습니다.

  • comments.service.ts
async getCommentById(id: number) {
    const comment = await this.commentsRepository.findOne({
        where: {
          	id,
        },
        relations: { // 추가
          	author: true
        }
    });
    if (!comment) throw new BadRequestException(`id: ${id} Comment는 존재하지 않습니다. `);
    return comment;
}

그리고 override 부분에서 계속해서 author: true가 반복됩니다. 따라서 묶어주도록 하겠습니다. FindManyOptions 기능을 이용하겠습니다.

  • comments/const/default-comments-find-options.const.ts
import { FindManyOptions } from "typeorm";
import { CommentsModel } from "../entity/comments.entity";

export const DEFAULT_COMMENT_FIND_OPTIONS: FindManyOptions<CommentsModel> = {
    relations: {
      	author: true
    },
}

서비스 코드를 바꿔줍니다.

  • comments.service.ts
paginateComments(
    dto: PaginateCommentsDto,
    postId: number,
) {
    return this.commonService.paginate(
        dto,
        this.commentsRepository,
        {
            ...DEFAULT_COMMENT_FIND_OPTIONS,
            where: {
                post: {
                  	id: postId,
                }
            },
        },
        `posts/${postId}/comments`,
    );
}

async getCommentById(id: number) {
    const comment = await this.commentsRepository.findOne({
        ...DEFAULT_COMMENT_FIND_OPTIONS,
        where: {
          	id,
        }
    });
    if (!comment) throw new BadRequestException(`id: ${id} Comment는 존재하지 않습니다. `);
    return comment;
}

{
    "data": [
        {
            "id": 1,
            "updatedAt": "2024-02-21T17:43:27.716Z",
            "createdAt": "2024-02-21T17:43:27.716Z",
            "comment": "강의 너무 좋아요!!!",
            "likeCount": 0,
            "author": {
                "id": 4,
                "updatedAt": "2024-02-18T02:33:34.030Z",
                "createdAt": "2024-02-18T02:33:34.030Z",
                "nickname": "codefactory123",
                "email": "codefactory123@codefactory.ai",
                "role": "USER"
            }
        }
    ],
    "cursor": {
        "after": null
    },
    "count": 1,
    "next": null
}

응답 데이터에서 보면 author정보는 사실상 id와 nickname만 필요합니다. 따라서 default option을 바꿔보도록 하겠습니다.

  • default-comments-find-options.const.ts
import { FindManyOptions } from "typeorm";
import { CommentsModel } from "../entity/comments.entity";

export const DEFAULT_COMMENT_FIND_OPTIONS: FindManyOptions<CommentsModel> = {
    relations: {
      	author: true
    },
    select: {
        author: {
            id: true,
            nickname: true,
        }
    }
}

같은 요청을 보내보겠습니다.

{
    "data": [
        {
            "id": 1,
            "updatedAt": "2024-02-21T17:43:27.716Z",
            "createdAt": "2024-02-21T17:43:27.716Z",
            "comment": "강의 너무 좋아요!!!",
            "likeCount": 0,
            "author": {
                "id": 4,
                "nickname": "codefactory123"
            }
        }
    ],
    "cursor": {
        "after": null
    },
    "count": 1,
    "next": null
}

🖊️PATCH: Comment API

  • dto/update-comments-dto.ts
import { PartialType } from "@nestjs/mapped-types";
import { CreateCommentsDto } from "./create-comments.dto";

// CreateCommentsDto의 부분 상속
export class UpdateCommentsDto extends PartialType(CreateCommentsDto) {}
  • comments.controller.ts
@Patch(':commentId')
@UseGuards(AccessTokenGuard)
async patchComment(
    @Param('commentId', ParseIntPipe) commentId: number,
    @Body() body: UpdateCommentsDto,
) {
    return this.commentsService.updateComment(
        body,
        commentId
    )
}
  • comments.service.ts
async updateComment(
    dto: UpdateCommentsDto,
    commentId: number,
) {
  	const comment = await this.commentsRepository.findOne({
        where: {
          	id,
        }
    });
    if (!comment) throw new BadRequestException(`존재하지 않는 댓글입니다. `);
  
    // preload 기능
    const prevComment = await this.commentsRepository.preload({
        id: commentId, // id 기반의 commentId 들어오게 됨
        ...dto, // 나머지는 dto내용으로 변경
    });

    const newComment = await this.commentsRepository.save(
      	prevComment,
    );

    return newComment;
}

포스트맨으로 테스트를 하겠습니다.

{
    "id": 1,
    "updatedAt": "2024-02-21T18:17:05.264Z",
    "createdAt": "2024-02-21T17:43:27.716Z",
    "comment": "NestJS 너무",
    "likeCount": 0
}

GET 요청으로 바꿔졌는지 확인을 해보겠습니다.

{
    "data": [
        {
            "id": 1,
            "updatedAt": "2024-02-21T18:17:05.264Z",
            "createdAt": "2024-02-21T17:43:27.716Z",
            "comment": "NestJS 너무",
            "likeCount": 0,
            "author": {
                "id": 4,
                "nickname": "codefactory123"
            }
        }
    ],
    "cursor": {
        "after": null
    },
    "count": 1,
    "next": null
}

🖊️DELETE: Comment API

  • comments.controller.ts
@Delete(':commentId')
@UseGuards(AccessTokenGuard)
async deleteComment(
  	@Param('commentId', ParseIntPipe) commentId: number,
) {
  	return this.commentsService.deleteComment(commentId);
}
  • comments.service.ts
async deleteComment(
  	id: number
) {
    const comment = await this.commentsRepository.findOne({
        where: {
          	id,
        }
    });
    if (!comment) throw new BadRequestException(`존재하지 않는 댓글입니다. `);

    await this.commentsRepository.delete(id);
    return id;
}

포스트맨으로 테스트를 해보겠습니다.

{
    "raw": [],
    "affected": 1
}

GET 요청으로 확인하면 4번이 사라진 것을 알 수 있습니다.


🖊️Path Parameter 검증 Middleware

현재 endpoint 경로는 post가 존재하면 에러를 던지는 코드는 존재하지 않습니다. 이 부분은 Middleware로 적용을 해보겠습니다. Middleware는 가장 앞단에서 먼저 필터링을 시작합니다.

컨트롤러에서 전반적으로 post가 존재하지 않으면 전부 BadRequestException을 던지도록 하겠습니다. 먼저 middleware 코드를 작성하겠습니다.

  • comments/middleware/post-exists.middleware.ts
import { BadRequestException, Injectable, NestMiddleware } from "@nestjs/common";
import { NextFunction, Request, Response } from "express";
import { PostsService } from "src/posts/posts.service";

@Injectable()
export class PostExistsMiddleware implements NestMiddleware {

    constructor(
    	private readonly postService: PostsService, 
    ) {}

    async use(req: Request, res: Response, next: NextFunction) {
        const postId = req.params.postId; // path parameter 안의 postId를 가져올 수 있습니다.
        if (!postId) throw new BadRequestException(`Post ID 파라미터는 필수입니다. `);
        const exists = await this.postService.checkPostExistsById(
          	parseInt(postId),
        );
        if (!exists) throw new BadRequestException(`Post가 존재하지 않습니다. `);
        next(); // next를 해줘야 다음단계로 이동
    }
}
  • posts.service.ts
async checkPostExistsById(id: number) {
    return this.postsRepository.exists({
        where: {
          	id,
        },
    })
}

이제 PostExists Middleware를 등록하겠습니다. Middleware를 등록하려면 등록할 module로 가서 implements를 합니다.

  • comments.module.ts
@Module({
    imports: [
        TypeOrmModule.forFeature([
          	CommentsModel,
        ]),
        CommonModule,
        AuthModule,
        UsersModule,
        PostsModule, // 등록
    ],
    controllers: [CommentsController],
    providers: [CommentsService],
})
export class CommentsModule implements NestModule {

    configure(consumer: MiddlewareConsumer) {
        consumer
            .apply(PostExistsMiddleware) // 적용할 Middleware
            .forRoutes(CommentsController); // 적용할 Controller 전체
    }
}

포스트맨으로 테스트를 하겠습니다. 존재하지 않는 Post를 조회하겠습니다. 만약 comment와 관련된 endpoint로 199번을 조회하면 전부 동일한 에러 메세지를 보내줄 것입니다.

{
    "message": "Post가 존재하지 않습니다. ",
    "error": "Bad Request",
    "statusCode": 400
}

이런식으로 Middleware를 적용하는 것이 좋은 사례인 것을 알 수 있습니다.

profile
블로그 이전 : https://medium.com/@jaegeunsong97

2개의 댓글

comment-user-thumbnail
2024년 4월 24일

I appreciate module nesting. This technique allows me to organize my application's functionality into hierarchical modules, promoting Wordle Unlimited code reusability, maintainability, and scalability.

답글 달기
comment-user-thumbnail
2024년 4월 24일

Really interesting programs to learn more. Explore the space and enjoy a deeper understanding of the rules. Continuously update advanced knowledge basketbros

답글 달기