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

[BE] docs: 작성한 리뷰 목록 조회, 회원용 리뷰 그룹 생성, 리뷰 그룹 정보, 리뷰 등록 API 문서를 작성한다. #1017

Open
wants to merge 10 commits into
base: develop
Choose a base branch
from
Open
8 changes: 6 additions & 2 deletions backend/src/docs/asciidoc/create-review.adoc
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
==== 리뷰 생성
==== 비회원이 리뷰 생성

operation::create-review[snippets="curl-request,request-fields,http-response"]
operation::create-review-by-guest[snippets="curl-request,request-fields,http-response"]

==== 회원이 리뷰 생성

operation::create-review-by-member[snippets="curl-request,request-fields,http-response"]

Comment on lines +1 to +7
Copy link
Contributor

Choose a reason for hiding this comment

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

하나의 api 를 사용하되, 세션에 따라서 회원과 비회원을 구분한다는 선택지도 있었을 것 같아요.
그것을 선택하지 않고 이렇게 api 를 분리한 이유가 있나요?

Copy link
Contributor

Choose a reason for hiding this comment

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

회원/비회원 리뷰 생성 API를 하나로 했을때를 생각해보았는데요.

    @PostMapping("/v2/reviews")
    public ResponseEntity<Void> createReview(
            @Valid @RequestBody ReviewRegisterRequest request,
            @MemberSession Member member
    ) {
        long savedReviewId = reviewRegisterService.registerReview(request, member);
        return ResponseEntity.created(URI.create("/reviews/" + savedReviewId)).build();
    }

이때, 비회원일 경우 세션이 없지만, resolver에서 예외를 터뜨리거나 하지 않고 컨트롤러까지 다시 member 객체를 null로 반환하는 형식으로 처리를 해줘야합니다. 그리고 서비스에서 이를 확인해서 비회원용 로직으로 처리하게 되겠죠. 이렇게, 하나의 API에서 세션이 null인 것을 예외가 아닌 비회원임으로 인식하고 처리하기 위해서 예외를 터뜨려야하는데 다르게 처리하는 등으로 로직이 복잡해진다고 생각했어요.

Copy link
Contributor

@nayonsoso nayonsoso Dec 20, 2024

Choose a reason for hiding this comment

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

지난 회의에서 이야기 나온 것처럼, 하나의 api를 여러곳에서 쓰고, 권한에 맞게 별도의 로직을 제공하려는 우리의 취지를 생각해봤어요. 그럼 하나의 api에서 처리하는게 맞는 것 같아요.

그리고 저도 확실하지 않아서 지피티 & 클로드에게 물어보니, 회원/비회원 기능을 하나의 api에서 제공하고 내부적으로 분기하는 것은 일반적인 패턴이라고 합니다. 아래의 예시 코드처럼요.

@Service
public class ProductService {
    public ProductResponse getProduct(Long id, User user) {
        Product product = productRepository.findById(id);
        
        ProductResponse response = new ProductResponse(product);
        
        if (user != null) {
            // 회원용 추가 정보 설정
            response.setPersonalizedPrice(calculatePersonalPrice(product, user));
            response.setUserSpecificData(getUserData(product, user));
        } else {
            // 비회원용 기본 정보만 설정
            response.setDefaultPrice(product.getPrice());
        }
        
        return response;
    }
}

Copy link
Contributor

Choose a reason for hiding this comment

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

  1. 위의 코드로 구현할 수 있지만 앞에서 언급한 문제가 여전히 존재한다고 생각해요.

하나의 API에서 세션이 null인 것을 예외가 아닌 비회원임으로 인식하고 처리하기 위해서, resolver에서 예외를 터뜨려야하는데 다르게 처리하는 등으로 로직이 복잡해진다고 생각했어요.

  1. api 재사용 논의와 관련해서는, 구현하면서 아직 하나의 api를 재사용하고 세션 권한 체계로 구분한다는 것의 상세구현이 그려지지 않았어요. 그래서 일단 세션을 나눠서 적용했을 때 문제가 없게 두가지 api로 나눠 구현했습니다..!

==== 그룹 코드가 올바르지 않은 경우

Expand Down
4 changes: 4 additions & 0 deletions backend/src/docs/asciidoc/review-list.adoc
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
==== 자신이 받은 리뷰 목록 조회

operation::received-review-list-with-pagination[snippets="curl-request,request-cookies,query-parameters,http-response,response-fields"]

==== 자신이 작성한 리뷰 목록 조회

operation::written-review-list-with-pagination[snippets="curl-request,query-parameters,http-response,response-fields"]
16 changes: 12 additions & 4 deletions backend/src/docs/asciidoc/reviewgroup.adoc
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
==== 리뷰 그룹 생성
==== 비회원용 리뷰 그룹 생성

operation::review-group-create[snippets="curl-request,request-fields,http-response,response-fields"]
operation::guest-review-group-create[snippets="curl-request,request-fields,http-response,response-fields"]

==== 리뷰 그룹 간단 정보 조회
==== 회원용 리뷰 그룹 생성

operation::review-group-summary[snippets="curl-request,http-response,response-fields"]
operation::member-review-group-create[snippets="curl-request,request-fields,http-response,response-fields"]

==== 회원이 만든 리뷰 그룹 간단 정보 조회

operation::member-review-group-summary[snippets="curl-request,http-response,response-fields"]

==== 비회원이 만든 리뷰 그룹 간단 정보 조회

operation::guest-review-group-summary[snippets="curl-request,http-response,response-fields"]

==== 리뷰 요청 코드, 확인 코드 일치 여부

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse;
import reviewme.review.service.dto.response.list.ReceivedReviewsResponse;
import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse;
import reviewme.review.service.dto.response.list.WrittenReviewsResponse;
import reviewme.reviewgroup.controller.ReviewGroupSession;
import reviewme.reviewgroup.domain.ReviewGroup;

Expand All @@ -39,6 +40,13 @@ public ResponseEntity<Void> createReview(@Valid @RequestBody ReviewRegisterReque
return ResponseEntity.created(URI.create("/reviews/" + savedReviewId)).build();
}

@PostMapping("/v2/reviews/member")
public ResponseEntity<Void> createReviewByMember(@Valid @RequestBody ReviewRegisterRequest request) {
// 회원 세션 추후 추가해야 함
Copy link
Contributor Author

Choose a reason for hiding this comment

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

다시 보니, 이 api는 /v2/reviews api 하나로 처리해도 상관없어 보여요.

  • 세션 유무에 따라서 내부 로직은 분기하면 됩니다.
  • 근데 요청 dto가 동일한데, 세션을 받아야 하는 이유가 있었나요? (생각이 안납니다..)

Copy link
Contributor

Choose a reason for hiding this comment

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

근데 요청 dto가 동일한데, 세션을 받아야 하는 이유가 있었나요?

회원이 리뷰를 작성할 땐, '내가 작성한 리뷰'에 추가해줘야 하니깐요!

long savedReviewId = reviewRegisterService.registerReview(request);
return ResponseEntity.created(URI.create("/reviews/" + savedReviewId)).build();
}

@GetMapping("/v2/reviews")
public ResponseEntity<ReceivedReviewsResponse> findReceivedReviews(
@RequestParam(required = false) Long lastReviewId,
Expand Down Expand Up @@ -75,4 +83,15 @@ public ResponseEntity<ReviewsGatheredBySectionResponse> getReceivedReviewsBySect
reviewGatheredLookupService.getReceivedReviewsBySectionId(reviewGroup, sectionId);
return ResponseEntity.ok(response);
}

@GetMapping("/v2/written")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

이 api의 url에서 "written"만 있는 것은 어떤 자원을 찾는지 명확하지 않아보여서 변경이 필요해 보이네요.

  1. 기존 보완 -> /reviews/written
  2. 신초의 의견을 반영한 authored 사용 -> /reviews/authored
  3. 특정 회원의 작성된 리뷰 목록 -> /users/{userId}/written-reviews (세션과 id 검증 필요)

public ResponseEntity<WrittenReviewsResponse> findWrittenReviews(
@RequestParam(required = false) Long lastReviewId,
nayonsoso marked this conversation as resolved.
Show resolved Hide resolved
@RequestParam(required = false) Integer size
// @MemberSession Member member
// TODO: 세션을 활용한 권한 체계에 따른 추가 조치 필요
nayonsoso marked this conversation as resolved.
Show resolved Hide resolved
) {
WrittenReviewsResponse response = reviewListLookupService.getWrittenReviews(lastReviewId, size);
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import reviewme.review.repository.ReviewRepository;
import reviewme.review.service.dto.response.list.ReceivedReviewsResponse;
import reviewme.review.service.dto.response.list.ReviewListElementResponse;
import reviewme.review.service.dto.response.list.WrittenReviewsResponse;
import reviewme.review.service.mapper.ReviewListMapper;
import reviewme.reviewgroup.domain.ReviewGroup;

Expand All @@ -29,6 +30,11 @@ public ReceivedReviewsResponse getReceivedReviews(Long lastReviewId, Integer siz
);
}

public WrittenReviewsResponse getWrittenReviews(Long lastReviewId, Integer size) {
// TODO: 생성일자 최신순 정렬
return null;
}

private long calculateLastReviewId(List<ReviewListElementResponse> elements) {
if (elements.isEmpty()) {
return 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package reviewme.review.service.dto.response.list;

import java.time.LocalDate;
import java.util.List;

public record WrittenReviewElementResponse(
long reviewId,
String revieweeName,
String projectName,
LocalDate createdAt,
String contentPreview,
List<ReviewCategoryResponse> categories
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package reviewme.review.service.dto.response.list;

import java.util.List;

public record WrittenReviewsResponse(
long memberId,
List<WrittenReviewElementResponse> reviews,
long lastReviewId,
boolean isLastPage
) {
}
Comment on lines +5 to +11
Copy link
Contributor

@nayonsoso nayonsoso Dec 19, 2024

Choose a reason for hiding this comment

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

이 dto 는 내가 작성한 리뷰들을 보여준다 생각하는데,
여기서 long memberId 를 포함하는 이유가 있나요?
어차피 자기 자신일거란 생각이 들어서요!

Copy link
Contributor

Choose a reason for hiding this comment

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

memberId를 내려줌으로써 좋은 점들이 있다고 생각했어요.
1 .프론트는 본인들이 요청을 한대로 제대로 내려줬는지 확인 가능
2. 오류가 생겼을 때 memberId를 뭐로 잘못 내려줬는지 확인 가능
내려줘서 나쁠 것 없는 고유한 정보라고 생각해서 추가해보았습니다~

Copy link
Contributor

@nayonsoso nayonsoso Dec 20, 2024

Choose a reason for hiding this comment

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

납득했습니다👍

Copy link
Contributor

Choose a reason for hiding this comment

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

납득 취소합니다.. (죄송 ㅠㅠ)
클라이언트 - 서버간에 로그인한 회원을 식별하는 정보는 JSESSION_ID 만 있게 하는게 맞다 생각합니다.

  1. 프론트는 본인들이 요청을 한대로 제대로 내려줬는지 확인

이 전제는 프론트가 로그인한 사용자의 memberId 가 뭔지를 기억하고 있어야 한다는건데..
그렇다면 클라이언트에 의존적인 구조라고 할 수 있습니다.

  1. 오류가 생겼을 때 memberId를 뭐로 잘못 내려줬는지 확인 가능

이건 프론트에서 확인해야할게 아니라는 생각이 드네요!

Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
import reviewme.reviewgroup.service.ReviewGroupLookupService;
import reviewme.reviewgroup.service.ReviewGroupService;
import reviewme.reviewgroup.service.dto.CheckValidAccessRequest;
import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest;
import reviewme.reviewgroup.service.dto.GuestReviewGroupCreationRequest;
import reviewme.reviewgroup.service.dto.MemberReviewGroupCreationRequest;
import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse;
import reviewme.reviewgroup.service.dto.ReviewGroupResponse;

Expand All @@ -32,9 +33,17 @@ public ResponseEntity<ReviewGroupResponse> getReviewGroupSummary(@RequestParam S

@PostMapping("/v2/groups")
public ResponseEntity<ReviewGroupCreationResponse> createReviewGroup(
@Valid @RequestBody ReviewGroupCreationRequest request
@Valid @RequestBody GuestReviewGroupCreationRequest request
) {
ReviewGroupCreationResponse response = reviewGroupService.createReviewGroup(request);
ReviewGroupCreationResponse response = reviewGroupService.createGuestReviewGroup(request);
return ResponseEntity.ok(response);
}

@PostMapping("/v2/groups/member")
public ResponseEntity<ReviewGroupCreationResponse> createReviewGroup(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

요것도 마찬가지로 groups랑 합칠 수 있을 것 같습니다

  • 세션 유무에 따른 분기처리

@Valid @RequestBody MemberReviewGroupCreationRequest request
) {
ReviewGroupCreationResponse response = reviewGroupService.createMemberReviewGroup(request);
return ResponseEntity.ok(response);
}
nayonsoso marked this conversation as resolved.
Show resolved Hide resolved

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ public ReviewGroupResponse getReviewGroupSummary(String reviewRequestCode) {
ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode)
.orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode));

return new ReviewGroupResponse(reviewGroup.getReviewee(), reviewGroup.getProjectName());
return new ReviewGroupResponse(null, reviewGroup.getReviewee(), reviewGroup.getProjectName());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reviewme.reviewgroup.service.dto.MemberReviewGroupCreationRequest;
import reviewme.reviewgroup.service.exception.ReviewGroupNotFoundByReviewRequestCodeException;
import reviewme.reviewgroup.service.exception.ReviewGroupUnauthorizedException;
import reviewme.reviewgroup.domain.ReviewGroup;
import reviewme.reviewgroup.repository.ReviewGroupRepository;
import reviewme.reviewgroup.service.dto.CheckValidAccessRequest;
import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest;
import reviewme.reviewgroup.service.dto.GuestReviewGroupCreationRequest;
import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse;
import reviewme.template.domain.Template;
import reviewme.template.repository.TemplateRepository;
Expand All @@ -26,7 +27,7 @@ public class ReviewGroupService {
private final TemplateRepository templateRepository;

@Transactional
public ReviewGroupCreationResponse createReviewGroup(ReviewGroupCreationRequest request) {
public ReviewGroupCreationResponse createGuestReviewGroup(GuestReviewGroupCreationRequest request) {
String reviewRequestCode;
do {
reviewRequestCode = randomCodeGenerator.generate(REVIEW_REQUEST_CODE_LENGTH);
Expand All @@ -44,6 +45,10 @@ public ReviewGroupCreationResponse createReviewGroup(ReviewGroupCreationRequest
return new ReviewGroupCreationResponse(reviewGroup.getReviewRequestCode());
}

public ReviewGroupCreationResponse createMemberReviewGroup(MemberReviewGroupCreationRequest request) {
return null;
}

@Transactional(readOnly = true)
public void checkGroupAccessCode(CheckValidAccessRequest request) {
ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(request.reviewRequestCode())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;

public record ReviewGroupCreationRequest(
public record GuestReviewGroupCreationRequest(

@NotEmpty(message = "리뷰이 이름을 입력해주세요.")
String revieweeName,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package reviewme.reviewgroup.service.dto;

import jakarta.validation.constraints.NotEmpty;

public record MemberReviewGroupCreationRequest(

@NotEmpty(message = "리뷰이 이름을 입력해주세요.")
String revieweeName,

@NotEmpty(message = "프로젝트 이름을 입력해주세요.")
String projectName
) {
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package reviewme.reviewgroup.service.dto;

import jakarta.annotation.Nullable;

public record ReviewGroupResponse(

@Nullable Long revieweeId,
String revieweeName,
String projectName
) {
Expand Down
92 changes: 89 additions & 3 deletions backend/src/test/java/reviewme/api/ReviewApiTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler;
import org.springframework.restdocs.payload.FieldDescriptor;
import org.springframework.restdocs.request.ParameterDescriptor;
import reviewme.template.domain.QuestionType;
import reviewme.review.service.dto.request.ReviewRegisterRequest;
import reviewme.review.service.dto.response.gathered.HighlightResponse;
import reviewme.review.service.dto.response.gathered.RangeResponse;
Expand All @@ -34,7 +33,10 @@
import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse;
import reviewme.review.service.dto.response.list.ReviewCategoryResponse;
import reviewme.review.service.dto.response.list.ReviewListElementResponse;
import reviewme.review.service.dto.response.list.WrittenReviewElementResponse;
import reviewme.review.service.dto.response.list.WrittenReviewsResponse;
import reviewme.reviewgroup.service.exception.ReviewGroupNotFoundByReviewRequestCodeException;
import reviewme.template.domain.QuestionType;

class ReviewApiTest extends ApiTest {

Expand All @@ -55,7 +57,7 @@ class ReviewApiTest extends ApiTest {
""";

@Test
void 리뷰를_등록한다() {
void 비회원이_리뷰를_등록한다() {
BDDMockito.given(reviewRegisterService.registerReview(any(ReviewRegisterRequest.class)))
.willReturn(1L);

Expand All @@ -69,7 +71,7 @@ class ReviewApiTest extends ApiTest {
};

RestDocumentationResultHandler handler = document(
"create-review",
"create-review-by-guest",
requestFields(requestFieldDescriptors)
);

Expand All @@ -81,6 +83,33 @@ class ReviewApiTest extends ApiTest {
.statusCode(201);
}

@Test
void 회원이_리뷰를_등록한다() {
BDDMockito.given(reviewRegisterService.registerReview(any(ReviewRegisterRequest.class)))
.willReturn(1L);

FieldDescriptor[] requestFieldDescriptors = {
fieldWithPath("reviewRequestCode").description("리뷰 요청 코드"),

fieldWithPath("answers[]").description("답변 목록"),
fieldWithPath("answers[].questionId").description("질문 ID"),
fieldWithPath("answers[].selectedOptionIds").description("선택한 옵션 ID 목록").optional(),
fieldWithPath("answers[].text").description("서술 답변").optional()
};

RestDocumentationResultHandler handler = document(
"create-review-by-member",
requestFields(requestFieldDescriptors)
);

givenWithSpec().log().all()
.body(request)
.when().post("/v2/reviews/member")
.then().log().all()
.apply(handler)
.statusCode(201);
}

@Test
void 리뷰_그룹_코드가_올바르지_않은_경우_예외가_발생한다() {
BDDMockito.given(reviewRegisterService.registerReview(any(ReviewRegisterRequest.class)))
Expand Down Expand Up @@ -314,4 +343,61 @@ class ReviewApiTest extends ApiTest {
.apply(handler)
.statusCode(200);
}

@Test
void 자신이_작성한_리뷰_목록을_조회한다() {
List<WrittenReviewElementResponse> writtenReviews = List.of(
new WrittenReviewElementResponse(1L, "테드1", "리뷰미", LocalDate.of(2024, 8, 2), "(리뷰 미리보기 1)",
List.of(new ReviewCategoryResponse(1L, "카테고리 1"))),
new WrittenReviewElementResponse(2L, "테드2", "리뷰미", LocalDate.of(2024, 8, 1), "(리뷰 미리보기 2)",
List.of(new ReviewCategoryResponse(2L, "카테고리 2")))
);
WrittenReviewsResponse response = new WrittenReviewsResponse(
1L, writtenReviews, 1L, true);
BDDMockito.given(reviewListLookupService.getWrittenReviews(anyLong(), anyInt()))
.willReturn(response);

CookieDescriptor[] cookieDescriptors = {
cookieWithName("JSESSIONID").description("세션 ID")
};

ParameterDescriptor[] queryParameter = {
parameterWithName("lastReviewId").description("페이지의 마지막 리뷰 ID - 기본으로 최신순 첫번째 페이지 응답"),
parameterWithName("size").description("페이지의 크기 - 기본으로 10개씩 응답")
};

FieldDescriptor[] responseFieldDescriptors = {
fieldWithPath("memberId").description("회원 ID"),
fieldWithPath("lastReviewId").description("페이지의 마지막 리뷰 ID"),
fieldWithPath("isLastPage").description("마지막 페이지 여부"),

fieldWithPath("reviews[]").description("리뷰 목록 (생성일 기준 내림차순 정렬)"),
fieldWithPath("reviews[].reviewId").description("리뷰 ID"),
fieldWithPath("reviews[].createdAt").description("리뷰 작성 날짜"),
fieldWithPath("reviews[].contentPreview").description("리뷰 미리보기"),
fieldWithPath("reviews[].revieweeName").description("리뷰이 이름"),
fieldWithPath("reviews[].projectName").description("프로젝트명"),

fieldWithPath("reviews[].categories[]").description("카테고리 목록"),
fieldWithPath("reviews[].categories[].optionId").description("카테고리 ID"),
fieldWithPath("reviews[].categories[].content").description("카테고리 내용")
};

RestDocumentationResultHandler handler = document(
"written-review-list-with-pagination",
requestCookies(cookieDescriptors),
queryParameters(queryParameter),
responseFields(responseFieldDescriptors)
);

givenWithSpec().log().all()
.cookie("JSESSIONID", "ASVNE1VAKDNV4")
// .queryParam("reviewRequestCode", "hello!!")
.queryParam("lastReviewId", "2")
.queryParam("size", "5")
.when().get("/v2/written")
.then().log().all()
.apply(handler)
.statusCode(200);
}
}
Loading
Loading