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

댓글 작성 기능 구현 #151

Merged
merged 16 commits into from
Jul 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion backend/src/main/java/com/votogether/config/OpenAPIConfig.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package com.votogether.config;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.security.SecurityScheme.In;
import io.swagger.v3.oas.models.security.SecurityScheme.Type;
import io.swagger.v3.oas.models.servers.Server;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
Expand Down Expand Up @@ -37,9 +42,22 @@ public OpenAPI openAPI() {
.version("v1.0.0")
.description("보투게더 API");

final SecurityScheme securityScheme = new SecurityScheme()
.type(Type.HTTP)
.in(In.HEADER)
.name("Authorization")
.scheme("bearer")
.bearerFormat("JWT")
.description("Bearer JWT");

final SecurityRequirement securityRequirement = new SecurityRequirement()
.addList("bearerAuth");
Comment on lines +45 to +54
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q
신기한 기능이네요!
swagger에 토큰을 적용해볼 수 있는 코드라고 생각하면 될까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞습니다 :) 해당 기능을 사용하면 Swagger 전역적으로 토큰을 적용할 수 있습니다. 현재는 Bearer 토큰을 설정해두었는데, 설정을 수정하면 Basic 토큰도 사용이 가능합니다!


Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q
이러한 설정을 따로 안해주면 어떤 문제가 생기는건가요??

Copy link
Collaborator Author

@woo-chang woo-chang Jul 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 궁금해서 직접 실험해 보았습니다!

스크린샷 2023-07-28 오전 11 17 32

위의 사진은 SecurityRequirement를 적용하기 전이고, 아래의 사진은 적용한 후의 모습입니다.

스크린샷 2023-07-28 오전 11 18 06

각 API 마다 Security를 설정할 수 있게 변한 것 같아요 :) 전역적으로 설정할 수 있는 버튼이 최상단에 있어서 있어도, 없어도 문제는 없을 것 같아요!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호 직접 실험까지! 감사합니다

return new OpenAPI()
.info(info)
.servers(List.of(devServer, prodServer));
.servers(List.of(devServer, prodServer))
.components(new Components().addSecuritySchemes("bearerAuth", securityScheme))
.security(List.of(securityRequirement));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.votogether.domain.post.controller;

import com.votogether.domain.member.entity.Member;
import com.votogether.domain.post.dto.request.CommentRegisterRequest;
import com.votogether.domain.post.service.PostCommentService;
import com.votogether.exception.ExceptionResponse;
import com.votogether.global.jwt.Auth;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "게시글 댓글", description = "게시글 댓글 API")
@RequiredArgsConstructor
@RequestMapping("/posts")
@RestController
public class PostCommentController {

private final PostCommentService postCommentService;

@Operation(summary = "게시글 댓글 작성", description = "게시글 댓글을 작성한다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "게시글 댓글 작성 성공"),
@ApiResponse(
responseCode = "404",
description = "존재하지 않는 게시글에 대한 댓글 작성",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
)
})
@PostMapping("/{postId}/comments")
public ResponseEntity<Void> createComment(
@Auth final Member member,
@PathVariable @Parameter(description = "댓글 작성 게시글 ID") final Long postId,
@Valid @RequestBody CommentRegisterRequest commentRegisterRequest
) {
postCommentService.createComment(member, postId, commentRegisterRequest);
return ResponseEntity.status(HttpStatus.CREATED).build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.votogether.domain.post.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

@Schema(description = "게시글 댓글 작성 요청")
public record CommentRegisterRequest(
@Schema(description = "댓글 내용", example = "hello")
@NotBlank(message = "댓글 내용은 존재해야 합니다.")
String content
) {
}
12 changes: 12 additions & 0 deletions backend/src/main/java/com/votogether/domain/post/entity/Post.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import com.votogether.domain.category.entity.Category;
import com.votogether.domain.common.BaseEntity;
import com.votogether.domain.member.entity.Member;
import com.votogether.domain.post.entity.comment.Comment;
import com.votogether.domain.post.exception.PostExceptionType;
import com.votogether.domain.vote.entity.Vote;
import com.votogether.exception.BadRequestException;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
Expand All @@ -15,7 +17,9 @@
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.IntStream;
Expand Down Expand Up @@ -50,6 +54,9 @@ public class Post extends BaseEntity {
@Column(columnDefinition = "datetime(2)", nullable = false)
private LocalDateTime deadline;

@OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST)
private List<Comment> comments = new ArrayList<>();
Comment on lines +57 to +58
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2
@onetomany 속성에 orphanRemoval = true도 추가해주는 것은 어떨까요? 그러면 comments에 있는 Comment와 해당 Comment의 DB정보의 생명주기가 일치해져서 더 관리하기가 쉬워질 것 같습니다.

Copy link
Collaborator Author

@woo-chang woo-chang Jul 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 의견 감사합니다 👍

해당 속성은 댓글의 post 값이 null이 되었을 때 삭제 쿼리가 날라가는 것으로 알고 있습니다 ㅎㅎ 보통 댓글을 삭제하는 경우를 생각해 보았을 때 ID를 이미 알고 있는 상황이라고 생각이 들어요!

ID를 알고 있다면 해당 ID로 삭제 쿼리를 날리는 것이 일반적인 삭제 로직이 될 것 같은데 어떤 상황에서 해당 기능을 활용할 수 있는지 궁금합니다 :)


@Builder
private Post(
final Member member,
Expand Down Expand Up @@ -129,4 +136,9 @@ private void validatePostOption(PostOption postOption) {
}
}

public void addComment(final Comment comment) {
comments.add(comment);
comment.setPost(this);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.votogether.domain.post.entity.comment;

import com.votogether.domain.common.BaseEntity;
import com.votogether.domain.member.entity.Member;
import com.votogether.domain.post.entity.Post;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Comment extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Setter
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private Post post;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;

@Embedded
private Content content;

@Builder
private Comment(final Post post, final Member member, final String content) {
this.post = post;
this.member = member;
this.content = new Content(content);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.votogether.domain.post.entity.comment;

import com.votogether.domain.post.exception.CommentExceptionType;
import com.votogether.exception.BadRequestException;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Embeddable
class Content {

private static final int MAXIMUM_LENGTH = 500;

@Column(name = "content", nullable = false, length = MAXIMUM_LENGTH)
private String value;

public Content(final String value) {
validate(value);
this.value = value;
}

private void validate(final String content) {
if (content.length() > MAXIMUM_LENGTH) {
throw new BadRequestException(CommentExceptionType.INVALID_CONTENT_LENGTH);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.votogether.domain.post.exception;

import com.votogether.exception.ExceptionType;
import lombok.Getter;

@Getter
public enum CommentExceptionType implements ExceptionType {

INVALID_CONTENT_LENGTH(2000, "유효하지 않은 댓글 길이입니다.");

private final int code;
private final String message;

CommentExceptionType(final int code, final String message) {
this.code = code;
this.message = message;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.votogether.domain.post.repository;

import com.votogether.domain.post.entity.comment.Comment;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CommentRepository extends JpaRepository<Comment, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.votogether.domain.post.service;

import com.votogether.domain.member.entity.Member;
import com.votogether.domain.post.dto.request.CommentRegisterRequest;
import com.votogether.domain.post.entity.Post;
import com.votogether.domain.post.entity.comment.Comment;
import com.votogether.domain.post.exception.PostExceptionType;
import com.votogether.domain.post.repository.PostRepository;
import com.votogether.exception.NotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class PostCommentService {

private final PostRepository postRepository;

@Transactional
public void createComment(
final Member member,
final Long postId,
final CommentRegisterRequest commentRegisterRequest
) {
final Post post = postRepository.findById(postId)
.orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND));

final Comment comment = Comment.builder()
.member(member)
.content(commentRegisterRequest.content())
.build();

post.addComment(comment);
Comment on lines +29 to +34
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 이전 방식보다 이렇게 작성하는 것이 더 좋아보인다고 생각했는데, 변경해주셨군요👍🏻

}

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
package com.votogether.exception;

public record ExceptionResponse(int code, String message) {
import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "예외 발생 응답")
public record ExceptionResponse(
@Schema(description = "예외 코드", example = "-9999")
int code,
@Schema(description = "예외 메시지", example = "알 수 없는 서버 예외가 발생하였습니다.")
String message
) {

public static ExceptionResponse from(final BaseException e) {
final ExceptionType exceptionType = e.getExceptionType();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.votogether.exception;

import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

@Slf4j
@RestControllerAdvice
Expand All @@ -17,6 +20,34 @@ public ResponseEntity<ExceptionResponse> handleException(final Exception e) {
.body(new ExceptionResponse(-9999, "알 수 없는 서버 에러가 발생했습니다."));
}

@ExceptionHandler
public ResponseEntity<ExceptionResponse> handleMethodArgumentTypeMismatchException(
final MethodArgumentTypeMismatchException e
) {
final String errorMessage = String.format(
"%s는 %s 타입이 필요합니다.",
e.getPropertyName(),
e.getRequiredType().getSimpleName()
);
log.warn("[" + e.getClass() + "] : " + errorMessage);
return ResponseEntity.badRequest()
.body(new ExceptionResponse(-9998, errorMessage));
}

@ExceptionHandler
public ResponseEntity<ExceptionResponse> handleMethodArgumentNotValidException(
final MethodArgumentNotValidException e
) {
final List<String> errorMessages = e.getBindingResult()
.getAllErrors()
.stream()
.map(error -> error.getDefaultMessage())
.toList();
log.warn("[" + e.getClass() + "] : " + errorMessages);
return ResponseEntity.badRequest()
.body(new ExceptionResponse(-9997, errorMessages.toString()));
}

@ExceptionHandler
public ResponseEntity<ExceptionResponse> handleBadRequestException(final BadRequestException e) {
log.warn("[" + e.getClass() + "] : " + e.getMessage());
Expand Down
2 changes: 2 additions & 0 deletions backend/src/main/java/com/votogether/global/jwt/Auth.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.votogether.global.jwt;

import io.swagger.v3.oas.annotations.Hidden;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Hidden
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q
이건 어떤 역할을 하는 어노테이션인가요??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 어노테이션은 스웨거에서 사용되는 어노테이션으로 @Hidden이 붙은 어노테이션은 Swagger에서 보이지 않게 됩니다!

스크린샷 2023-07-27 오후 9 52 04

붙이지 않고 사용하면 일반적인 요청에도 해당 멤버의 값들이 입력받아야 하는 값으로 인식해 Request에 그대로 노출되게 됩니다. 저희 프로젝트에서 Auth가 붙은 매개변수는 ArgumentResolver가 처리해서 넘겨주는 값이기에 요청 시 보여주지 않아도 된다고 판단하였습니다. 따라서 보이지 않도록 설정하였습니다 :)

어노테이션을 붙이면 아래와 같이 보이게 됩니다.

스크린샷 2023-07-27 오후 9 55 03

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auth {
Expand Down
4 changes: 4 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ logging:
server:
forward-headers-strategy: FRAMEWORK

springdoc:
swagger-ui:
enabled: ${SWAGGER_ENABLE}

votogether:
openapi:
dev-url: http://localhost:8080
Expand Down
Loading