From ecaa9ce56feb7a1118e7f4054b4ab8a02b9a6fcc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 07:34:35 +0000 Subject: [PATCH 1/2] Create PR for #368 From ea0fd7799d554dff66853bab3db4065297a1d73c Mon Sep 17 00:00:00 2001 From: hyxrxn Date: Tue, 24 Sep 2024 17:47:58 +0900 Subject: [PATCH 2/2] :feat: api versioning - read recipes - read likes recipes --- .../recipe/controller/RecipeController.java | 14 +++ .../dto/RecipeHomeWithMineResponse.java | 17 ++- .../dto/RecipeHomeWithMineResponseV1.java | 32 ++++++ .../recipe/repository/RecipeRepository.java | 31 ++++- .../recipe/service/RecipeService.java | 61 +++++++++- .../controller/RecipeControllerTest.java | 106 +++++++++++++++++- .../repository/RecipeRepositoryTest.java | 2 +- .../recipe/service/RecipeServiceTest.java | 8 +- 8 files changed, 256 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/net/pengcook/recipe/dto/RecipeHomeWithMineResponseV1.java diff --git a/backend/src/main/java/net/pengcook/recipe/controller/RecipeController.java b/backend/src/main/java/net/pengcook/recipe/controller/RecipeController.java index ecf775d6..57e8fbde 100644 --- a/backend/src/main/java/net/pengcook/recipe/controller/RecipeController.java +++ b/backend/src/main/java/net/pengcook/recipe/controller/RecipeController.java @@ -8,6 +8,7 @@ import net.pengcook.recipe.dto.PageRecipeRequest; import net.pengcook.recipe.dto.RecipeDescriptionResponse; import net.pengcook.recipe.dto.RecipeHomeWithMineResponse; +import net.pengcook.recipe.dto.RecipeHomeWithMineResponseV1; import net.pengcook.recipe.dto.RecipeRequest; import net.pengcook.recipe.dto.RecipeResponse; import net.pengcook.recipe.dto.RecipeStepResponse; @@ -40,11 +41,24 @@ public List readRecipes( return recipeService.readRecipes(userInfo, pageRecipeRequest); } + @GetMapping(produces = "application/vnd.pengcook.v1+json") + public List readRecipesV1( + @LoginUser UserInfo userInfo, + @ModelAttribute @Valid PageRecipeRequest pageRecipeRequest + ) { + return recipeService.readRecipesV1(userInfo, pageRecipeRequest); + } + @GetMapping("/likes") public List readLikeRecipes(@LoginUser UserInfo userInfo) { return recipeService.readLikeRecipes(userInfo); } + @GetMapping(value = "/likes", produces = "application/vnd.pengcook.v1+json") + public List readLikeRecipesV1(@LoginUser UserInfo userInfo) { + return recipeService.readLikeRecipesV1(userInfo); + } + @PostMapping @ResponseStatus(HttpStatus.CREATED) public RecipeResponse createRecipe(@LoginUser UserInfo userInfo, @RequestBody @Valid RecipeRequest recipeRequest) { diff --git a/backend/src/main/java/net/pengcook/recipe/dto/RecipeHomeWithMineResponse.java b/backend/src/main/java/net/pengcook/recipe/dto/RecipeHomeWithMineResponse.java index 2e261155..7bca43cc 100644 --- a/backend/src/main/java/net/pengcook/recipe/dto/RecipeHomeWithMineResponse.java +++ b/backend/src/main/java/net/pengcook/recipe/dto/RecipeHomeWithMineResponse.java @@ -1,31 +1,46 @@ package net.pengcook.recipe.dto; import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; import net.pengcook.authentication.domain.UserInfo; public record RecipeHomeWithMineResponse( long recipeId, String title, AuthorResponse author, + LocalTime cookingTime, String thumbnail, + int difficulty, int likeCount, int commentCount, + String description, LocalDateTime createdAt, + List category, + List ingredient, boolean mine ) { public RecipeHomeWithMineResponse( UserInfo userInfo, - RecipeHomeResponse firstResponse + RecipeDataResponse firstResponse, + List category, + List ingredient + ) { this( firstResponse.recipeId(), firstResponse.title(), new AuthorResponse(firstResponse.authorId(), firstResponse.authorName(), firstResponse.authorImage()), + firstResponse.cookingTime(), firstResponse.thumbnail(), + firstResponse.difficulty(), firstResponse.likeCount(), firstResponse.commentCount(), + firstResponse.description(), firstResponse.createdAt(), + category, + ingredient, userInfo.isSameUser(firstResponse.authorId()) ); } diff --git a/backend/src/main/java/net/pengcook/recipe/dto/RecipeHomeWithMineResponseV1.java b/backend/src/main/java/net/pengcook/recipe/dto/RecipeHomeWithMineResponseV1.java new file mode 100644 index 00000000..cbde4e90 --- /dev/null +++ b/backend/src/main/java/net/pengcook/recipe/dto/RecipeHomeWithMineResponseV1.java @@ -0,0 +1,32 @@ +package net.pengcook.recipe.dto; + +import java.time.LocalDateTime; +import net.pengcook.authentication.domain.UserInfo; + +public record RecipeHomeWithMineResponseV1( + long recipeId, + String title, + AuthorResponse author, + String thumbnail, + int likeCount, + int commentCount, + LocalDateTime createdAt, + boolean mine +) { + + public RecipeHomeWithMineResponseV1( + UserInfo userInfo, + RecipeHomeResponse firstResponse + ) { + this( + firstResponse.recipeId(), + firstResponse.title(), + new AuthorResponse(firstResponse.authorId(), firstResponse.authorName(), firstResponse.authorImage()), + firstResponse.thumbnail(), + firstResponse.likeCount(), + firstResponse.commentCount(), + firstResponse.createdAt(), + userInfo.isSameUser(firstResponse.authorId()) + ); + } +} diff --git a/backend/src/main/java/net/pengcook/recipe/repository/RecipeRepository.java b/backend/src/main/java/net/pengcook/recipe/repository/RecipeRepository.java index 8c1d970b..510eae1d 100644 --- a/backend/src/main/java/net/pengcook/recipe/repository/RecipeRepository.java +++ b/backend/src/main/java/net/pengcook/recipe/repository/RecipeRepository.java @@ -29,6 +29,35 @@ List findRecipeIdsByCategoryAndKeyword( @Param("userId") @Nullable Long userId ); + @Query(""" + SELECT new net.pengcook.recipe.dto.RecipeDataResponse( + r.id, + r.title, + r.author.id, + r.author.username, + r.author.image, + r.cookingTime, + r.thumbnail, + r.difficulty, + r.likeCount, + r.commentCount, + r.description, + r.createdAt, + c.id, + c.name, + i.id, + i.name, + ir.requirement + ) + FROM Recipe r + JOIN FETCH CategoryRecipe cr ON cr.recipe = r + JOIN FETCH Category c ON cr.category = c + JOIN FETCH IngredientRecipe ir ON ir.recipe = r + JOIN FETCH Ingredient i ON ir.ingredient = i + WHERE r.id IN :recipeIds + """) + List findRecipeData(List recipeIds); + @Query(""" SELECT new net.pengcook.recipe.dto.RecipeHomeResponse( r.id, @@ -44,7 +73,7 @@ List findRecipeIdsByCategoryAndKeyword( FROM Recipe r WHERE r.id IN :recipeIds """) - List findRecipeData(List recipeIds); + List findRecipeDataV1(List recipeIds); @Query(""" SELECT new net.pengcook.recipe.dto.RecipeDataResponse( diff --git a/backend/src/main/java/net/pengcook/recipe/service/RecipeService.java b/backend/src/main/java/net/pengcook/recipe/service/RecipeService.java index 8da1eac5..4a0d107d 100644 --- a/backend/src/main/java/net/pengcook/recipe/service/RecipeService.java +++ b/backend/src/main/java/net/pengcook/recipe/service/RecipeService.java @@ -1,6 +1,7 @@ package net.pengcook.recipe.service; import java.time.LocalTime; +import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -22,6 +23,7 @@ import net.pengcook.recipe.dto.RecipeDescriptionResponse; import net.pengcook.recipe.dto.RecipeHomeResponse; import net.pengcook.recipe.dto.RecipeHomeWithMineResponse; +import net.pengcook.recipe.dto.RecipeHomeWithMineResponseV1; import net.pengcook.recipe.dto.RecipeRequest; import net.pengcook.recipe.dto.RecipeResponse; import net.pengcook.recipe.exception.UnauthorizedException; @@ -58,22 +60,44 @@ public List readRecipes(UserInfo userInfo, PageRecip pageRecipeRequest.userId() ); - List recipeHomeResponses = recipeRepository.findRecipeData(recipeIds); + List recipeDataResponses = recipeRepository.findRecipeData(recipeIds); + return convertToMainRecipeResponses(userInfo, recipeDataResponses); + } + + @Transactional(readOnly = true) + public List readRecipesV1(UserInfo userInfo, PageRecipeRequest pageRecipeRequest) { + Pageable pageable = pageRecipeRequest.getPageable(); + List recipeIds = recipeRepository.findRecipeIdsByCategoryAndKeyword( + pageable, + pageRecipeRequest.category(), + pageRecipeRequest.keyword(), + pageRecipeRequest.userId() + ); + + List recipeHomeResponses = recipeRepository.findRecipeDataV1(recipeIds); return recipeHomeResponses.stream() - .map(recipeHomeResponse -> new RecipeHomeWithMineResponse(userInfo, recipeHomeResponse)) - .sorted(Comparator.comparing(RecipeHomeWithMineResponse::recipeId).reversed()) + .map(recipeHomeResponse -> new RecipeHomeWithMineResponseV1(userInfo, recipeHomeResponse)) + .sorted(Comparator.comparing(RecipeHomeWithMineResponseV1::recipeId).reversed()) .toList(); } @Transactional(readOnly = true) public List readLikeRecipes(UserInfo userInfo) { List likeRecipeIds = likeRepository.findRecipeIdsByUserId(userInfo.getId()); - List recipeHomeResponses = recipeRepository.findRecipeData(likeRecipeIds); + List recipeDataResponses = recipeRepository.findRecipeData(likeRecipeIds); + + return convertToMainRecipeResponses(userInfo, recipeDataResponses); + } + + @Transactional(readOnly = true) + public List readLikeRecipesV1(UserInfo userInfo) { + List likeRecipeIds = likeRepository.findRecipeIdsByUserId(userInfo.getId()); + List recipeHomeResponses = recipeRepository.findRecipeDataV1(likeRecipeIds); return recipeHomeResponses.stream() - .map(recipeHomeResponse -> new RecipeHomeWithMineResponse(userInfo, recipeHomeResponse)) - .sorted(Comparator.comparing(RecipeHomeWithMineResponse::recipeId).reversed()) + .map(recipeHomeResponse -> new RecipeHomeWithMineResponseV1(userInfo, recipeHomeResponse)) + .sorted(Comparator.comparing(RecipeHomeWithMineResponseV1::recipeId).reversed()) .toList(); } @@ -125,6 +149,31 @@ public void deleteRecipe(UserInfo userInfo, long recipeId) { }); } + private List convertToMainRecipeResponses( + UserInfo userInfo, + List recipeDataResponses + ) { + Collection> groupedRecipeData = recipeDataResponses.stream() + .collect(Collectors.groupingBy(RecipeDataResponse::recipeId)) + .values(); + + return groupedRecipeData.stream() + .map(data -> getMainRecipeResponse(userInfo, data)) + .sorted(Comparator.comparing(RecipeHomeWithMineResponse::recipeId).reversed()) + .collect(Collectors.toList()); + } + + private RecipeHomeWithMineResponse getMainRecipeResponse(UserInfo userInfo, List groupedResponses) { + RecipeDataResponse firstResponse = groupedResponses.getFirst(); + + return new RecipeHomeWithMineResponse( + userInfo, + firstResponse, + getCategoryResponses(groupedResponses), + getIngredientResponses(groupedResponses) + ); + } + private List getIngredientResponses(List groupedResponses) { return groupedResponses.stream() .map(r -> new IngredientResponse(r.ingredientId(), r.ingredientName(), r.ingredientRequirement())) diff --git a/backend/src/test/java/net/pengcook/recipe/controller/RecipeControllerTest.java b/backend/src/test/java/net/pengcook/recipe/controller/RecipeControllerTest.java index 26c7220e..1dc9c0af 100644 --- a/backend/src/test/java/net/pengcook/recipe/controller/RecipeControllerTest.java +++ b/backend/src/test/java/net/pengcook/recipe/controller/RecipeControllerTest.java @@ -24,6 +24,8 @@ import net.pengcook.recipe.dto.CategoryResponse; import net.pengcook.recipe.dto.IngredientResponse; import net.pengcook.recipe.dto.RecipeDescriptionResponse; +import net.pengcook.recipe.dto.RecipeHomeWithMineResponse; +import net.pengcook.recipe.dto.RecipeHomeWithMineResponseV1; import net.pengcook.recipe.dto.RecipeRequest; import net.pengcook.recipe.dto.RecipeStepRequest; import org.junit.jupiter.api.DisplayName; @@ -63,10 +65,20 @@ void readRecipes() { fieldWithPath("[].author.authorId").description("작성자 아이디"), fieldWithPath("[].author.authorName").description("작성자 이름"), fieldWithPath("[].author.authorImage").description("작성자 이미지"), + fieldWithPath("[].cookingTime").description("조리 시간"), fieldWithPath("[].thumbnail").description("썸네일 이미지"), + fieldWithPath("[].difficulty").description("난이도"), fieldWithPath("[].likeCount").description("좋아요 수"), fieldWithPath("[].commentCount").description("댓글 수"), + fieldWithPath("[].description").description("레시피 설명"), fieldWithPath("[].createdAt").description("레시피 생성일시"), + fieldWithPath("[].category").description("카테고리 목록"), + fieldWithPath("[].category[].categoryId").description("카테고리 아이디"), + fieldWithPath("[].category[].categoryName").description("카테고리 이름"), + fieldWithPath("[].ingredient").description("재료 목록"), + fieldWithPath("[].ingredient[].ingredientId").description("재료 아이디"), + fieldWithPath("[].ingredient[].ingredientName").description("재료 이름"), + fieldWithPath("[].ingredient[].requirement").description("재료 필수 여부"), fieldWithPath("[].mine").description("조회자 작성여부") ))) .queryParam("pageNumber", 0) @@ -74,7 +86,51 @@ void readRecipes() { .when() .get("/recipes") .then().log().all() - .body("size()", is(3)); + .body("size()", is(3)) + .extract() + .jsonPath() + .getList(".", RecipeHomeWithMineResponse.class); + } + + @Test + @WithLoginUser(email = "loki@pengcook.net") + @DisplayName("레시피 개요 목록을 조회한다. V1") + void readRecipesV1() { + RestAssured.given(spec).log().all() + .filter(document(DEFAULT_RESTDOCS_PATH, + "특정 페이지의 레시피 목록을 조회합니다.", + "레시피 조회 API", + queryParameters( + parameterWithName("pageNumber").description("페이지 번호"), + parameterWithName("pageSize").description("페이지 크기"), + parameterWithName("category").description("조회 카테고리").optional(), + parameterWithName("keyword").description("제목 또는 설명 검색 키워드").optional(), + parameterWithName("userId").description("작성자 아이디").optional() + ), + responseFields( + fieldWithPath("[]").description("레시피 목록"), + fieldWithPath("[].recipeId").description("레시피 아이디"), + fieldWithPath("[].title").description("레시피 제목"), + fieldWithPath("[].author").description("작성자 정보"), + fieldWithPath("[].author.authorId").description("작성자 아이디"), + fieldWithPath("[].author.authorName").description("작성자 이름"), + fieldWithPath("[].author.authorImage").description("작성자 이미지"), + fieldWithPath("[].thumbnail").description("썸네일 이미지"), + fieldWithPath("[].likeCount").description("좋아요 수"), + fieldWithPath("[].commentCount").description("댓글 수"), + fieldWithPath("[].createdAt").description("레시피 생성일시"), + fieldWithPath("[].mine").description("조회자 작성여부") + ))) + .accept("application/vnd.pengcook.v1+json") + .queryParam("pageNumber", 0) + .queryParam("pageSize", 3) + .when() + .get("/recipes") + .then().log().all() + .body("size()", is(3)) + .extract() + .jsonPath() + .getList(".", RecipeHomeWithMineResponseV1.class); } @ParameterizedTest @@ -122,6 +178,48 @@ void readRecipesWhenInvalidPageSize(String pageSize) { @WithLoginUser(email = "loki@pengcook.net") @DisplayName("내가 좋아요한 레시피 개요 목록을 조회한다.") void readLikeRecipes() { + RestAssured.given(spec).log().all() + .filter(document(DEFAULT_RESTDOCS_PATH, + "내가 좋아요한 레시피 목록을 조회합니다.", + "좋아요한 레시피 조회 API", + responseFields( + fieldWithPath("[]").description("레시피 목록"), + fieldWithPath("[].recipeId").description("레시피 아이디"), + fieldWithPath("[].title").description("레시피 제목"), + fieldWithPath("[].author").description("작성자 정보"), + fieldWithPath("[].author.authorId").description("작성자 아이디"), + fieldWithPath("[].author.authorName").description("작성자 이름"), + fieldWithPath("[].author.authorImage").description("작성자 이미지"), + fieldWithPath("[].cookingTime").description("조리 시간"), + fieldWithPath("[].thumbnail").description("썸네일 이미지"), + fieldWithPath("[].difficulty").description("난이도"), + fieldWithPath("[].likeCount").description("좋아요 수"), + fieldWithPath("[].commentCount").description("댓글 수"), + fieldWithPath("[].description").description("레시피 설명"), + fieldWithPath("[].createdAt").description("레시피 생성일시"), + fieldWithPath("[].category").description("카테고리 목록"), + fieldWithPath("[].category[].categoryId").description("카테고리 아이디"), + fieldWithPath("[].category[].categoryName").description("카테고리 이름"), + fieldWithPath("[].ingredient").description("재료 목록"), + fieldWithPath("[].ingredient[].ingredientId").description("재료 아이디"), + fieldWithPath("[].ingredient[].ingredientName").description("재료 이름"), + fieldWithPath("[].ingredient[].requirement").description("재료 필수 여부"), + fieldWithPath("[].mine").description("조회자 작성여부") + ))) + .when() + .get("/recipes/likes") + .then().log().all() + .body("size()", is(1)) + .extract() + .jsonPath() + .getList(".", RecipeHomeWithMineResponse.class); + } + + @Test + @Sql({"/data/recipe.sql", "/data/like.sql"}) + @WithLoginUser(email = "loki@pengcook.net") + @DisplayName("내가 좋아요한 레시피 개요 목록을 조회한다. V1") + void readLikeRecipesV1() { RestAssured.given(spec).log().all() .filter(document(DEFAULT_RESTDOCS_PATH, "내가 좋아요한 레시피 목록을 조회합니다.", @@ -140,10 +238,14 @@ void readLikeRecipes() { fieldWithPath("[].createdAt").description("레시피 생성일시"), fieldWithPath("[].mine").description("조회자 작성여부") ))) + .accept("application/vnd.pengcook.v1+json") .when() .get("/recipes/likes") .then().log().all() - .body("size()", is(1)); + .body("size()", is(1)) + .extract() + .jsonPath() + .getList(".", RecipeHomeWithMineResponseV1.class); } @Test diff --git a/backend/src/test/java/net/pengcook/recipe/repository/RecipeRepositoryTest.java b/backend/src/test/java/net/pengcook/recipe/repository/RecipeRepositoryTest.java index fe5b4935..a6135a97 100644 --- a/backend/src/test/java/net/pengcook/recipe/repository/RecipeRepositoryTest.java +++ b/backend/src/test/java/net/pengcook/recipe/repository/RecipeRepositoryTest.java @@ -38,7 +38,7 @@ void findRecipeData() { RecipeHomeResponse expectedData = new RecipeHomeResponse(4, "토마토스파게티", 1, "loki", "loki.jpg", "토마토스파게티이미지.jpg", 2, 0, LocalDateTime.of(2024, 7, 2, 13, 0, 0)); - List recipeData = repository.findRecipeData(recipeIds); + List recipeData = repository.findRecipeDataV1(recipeIds); assertAll( () -> assertThat(recipeData).hasSize(2), diff --git a/backend/src/test/java/net/pengcook/recipe/service/RecipeServiceTest.java b/backend/src/test/java/net/pengcook/recipe/service/RecipeServiceTest.java index 5e793b84..c7309021 100644 --- a/backend/src/test/java/net/pengcook/recipe/service/RecipeServiceTest.java +++ b/backend/src/test/java/net/pengcook/recipe/service/RecipeServiceTest.java @@ -9,7 +9,7 @@ import net.pengcook.ingredient.domain.Requirement; import net.pengcook.ingredient.dto.IngredientCreateRequest; import net.pengcook.recipe.dto.PageRecipeRequest; -import net.pengcook.recipe.dto.RecipeHomeWithMineResponse; +import net.pengcook.recipe.dto.RecipeHomeWithMineResponseV1; import net.pengcook.recipe.dto.RecipeRequest; import net.pengcook.recipe.dto.RecipeResponse; import net.pengcook.recipe.dto.RecipeStepRequest; @@ -40,7 +40,7 @@ class RecipeServiceTest { void readRecipes(int pageNumber, int pageSize, int expectedFirstRecipeId) { UserInfo userInfo = new UserInfo(1L, "loki@pengcook.net"); PageRecipeRequest pageRecipeRequest = new PageRecipeRequest(pageNumber, pageSize, null, null, null); - List recipeHomeWithMineResponses = recipeService.readRecipes(userInfo, + List recipeHomeWithMineResponses = recipeService.readRecipesV1(userInfo, pageRecipeRequest); assertThat(recipeHomeWithMineResponses.getFirst().recipeId()).isEqualTo(expectedFirstRecipeId); @@ -51,7 +51,7 @@ void readRecipes(int pageNumber, int pageSize, int expectedFirstRecipeId) { void readRecipesWithUserInfo() { UserInfo userInfo = new UserInfo(1L, "loki@pengcook.net"); PageRecipeRequest pageRecipeRequest = new PageRecipeRequest(0, 2, null, null, null); - List recipeHomeWithMineResponses = recipeService.readRecipes(userInfo, + List recipeHomeWithMineResponses = recipeService.readRecipesV1(userInfo, pageRecipeRequest); assertAll( @@ -66,7 +66,7 @@ void readRecipesWithUserInfo() { void readLikeRecipes() { UserInfo userInfo = new UserInfo(1L, "loki@pengcook.net"); - List actual = recipeService.readLikeRecipes(userInfo); + List actual = recipeService.readLikeRecipesV1(userInfo); assertAll( () -> assertThat(actual.size()).isOne(),