From f9c1d41501ce467b9010bf924744fed9f0800880 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Thu, 5 Feb 2026 18:39:32 -0600 Subject: [PATCH] MME-8 Add endpoints necessary for recipe main image selection. Refactor SliceView. --- .../api/image/ImageControllerTests.java | 74 ++++++++++++++++--- .../api/recipe/RecipesControllerTests.java | 2 + .../api/image/ImageController.java | 18 +++++ .../api/image/ImageRepository.java | 7 ++ .../mealsmadeeasy/api/image/ImageService.java | 4 + .../api/image/S3ImageService.java | 12 +++ .../api/recipe/RecipeRepository.java | 5 ++ .../api/recipe/RecipeService.java | 8 ++ .../api/recipe/RecipesController.java | 14 ++-- .../comment/RecipeCommentRepository.java | 4 + .../recipe/comment/RecipeCommentService.java | 3 +- .../comment/RecipeCommentServiceImpl.java | 5 ++ .../api/sliceview/SliceView.java | 9 +++ .../api/sliceview/SliceViewService.java | 19 ++--- 14 files changed, 155 insertions(+), 29 deletions(-) create mode 100644 src/main/java/app/mealsmadeeasy/api/sliceview/SliceView.java diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java index b491e82..51c1582 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java @@ -130,6 +130,24 @@ public class ImageControllerTests { } } + @Test + public void getOwnedImages() throws Exception { + final User owner = this.seedUser(); + this.seedImage(owner); + this.mockMvc.perform( + get("/images") + .header("Authorization", "Bearer " + this.getAccessToken(owner)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.count").value(1)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content").isNotEmpty()) + .andExpect(jsonPath("$.content[0].url").isNotEmpty()) + .andExpect(jsonPath("$.slice.size").isNotEmpty()) + .andExpect(jsonPath("$.slice.number", is(0))) + .andExpect(jsonPath("$.slice.hasNext", is(false))); + } + @Test public void getImageForOwner() throws Exception { final User owner = this.seedUser(); @@ -204,10 +222,6 @@ public class ImageControllerTests { .param("caption", "HAL 9000, from 2001: A Space Odyssey") .param("isPublic", "true") .header("Authorization", "Bearer " + accessToken) - .with(req -> { - req.setMethod("PUT"); - return req; - }) ) .andExpect(status().isCreated()) .andExpect(jsonPath("$.created").exists()) @@ -231,7 +245,7 @@ public class ImageControllerTests { final ImageUpdateBody body = new ImageUpdateBody(); body.setAlt("HAL 9000"); this.mockMvc.perform( - post(getImageUrl(owner, image)) + put(getImageUrl(owner, image)) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -249,7 +263,7 @@ public class ImageControllerTests { final ImageUpdateBody body = new ImageUpdateBody(); body.setCaption("HAL 9000 from 2001: A Space Odyssey"); this.mockMvc.perform( - post(getImageUrl(owner, image)) + put(getImageUrl(owner, image)) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -267,7 +281,7 @@ public class ImageControllerTests { final ImageUpdateBody body = new ImageUpdateBody(); body.setIsPublic(true); this.mockMvc.perform( - post(getImageUrl(owner, image)) + put(getImageUrl(owner, image)) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -289,7 +303,7 @@ public class ImageControllerTests { body.setViewersToAdd(viewerUsernames); this.mockMvc.perform( - post(getImageUrl(owner, image)) + put(getImageUrl(owner, image)) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -322,7 +336,7 @@ public class ImageControllerTests { body.setViewersToRemove(Set.of(ownerViewerImage.viewer().getUsername())); this.mockMvc.perform( - post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) + put(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -339,7 +353,7 @@ public class ImageControllerTests { final ImageUpdateBody body = new ImageUpdateBody(); body.setClearAllViewers(true); this.mockMvc.perform( - post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) + put(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -355,7 +369,7 @@ public class ImageControllerTests { final String accessToken = this.getAccessToken(ownerViewerImage.viewer()); // viewer final ImageUpdateBody body = new ImageUpdateBody(); this.mockMvc.perform( - post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) + put(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -389,4 +403,42 @@ public class ImageControllerTests { .andExpect(status().isForbidden()); } + @Test + public void whenImageExists_existsIsTrue() throws Exception { + final User owner = this.seedUser(); + final Image image = this.seedImage(owner); + + this.mockMvc.perform( + get("/images/{username}/{filename}/exists", image.getOwner().getUsername(), image.getUserFilename()) + .header("Authorization", "Bearer " + this.getAccessToken(owner)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.exists").value(true)); + } + + @Test + public void whenImageDoesNotExist_existsIsFalse() throws Exception { + final User owner = this.seedUser(); + this.mockMvc.perform( + get("/images/{username}/fake-filename.jpeg/exists", owner.getUsername()) + .header("Authorization", "Bearer " + this.getAccessToken(owner)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.exists").value(false)); + } + + @Test + public void cannotDoExistsOnInaccessibleImage() throws Exception { + final User owner = this.seedUser(); + final User viewer = this.seedUser(); + final Image image = this.seedImage(owner); + final String viewerAccessToken = this.getAccessToken(viewer); + + this.mockMvc.perform( + get("/images/{username}/{filename}/exists", owner.getUsername(), image.getUserFilename()) + .header("Authorization", "Bearer " + viewerAccessToken) + ) + .andExpect(status().isForbidden()); + } + } diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipesControllerTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipesControllerTests.java index 1a7d5c2..a26f5e3 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipesControllerTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipesControllerTests.java @@ -182,6 +182,7 @@ public class RecipesControllerTests { final Recipe recipe = this.createTestRecipe(owner, true); this.mockMvc.perform(get("/recipes")) .andExpect(status().isOk()) + .andExpect(jsonPath("$.count").isNotEmpty()) .andExpect(jsonPath("$.slice.number").value(0)) .andExpect(jsonPath("$.slice.size").value(20)) .andExpect(jsonPath("$.content").isArray()) @@ -200,6 +201,7 @@ public class RecipesControllerTests { .header("Authorization", "Bearer " + loginDetails.getAccessToken().getToken()) ) .andExpect(status().isOk()) + .andExpect(jsonPath("$.count").isNotEmpty()) .andExpect(jsonPath("$.slice.number").value(0)) .andExpect(jsonPath("$.slice.size").value(20)) .andExpect(jsonPath("$.content").isArray()) diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageController.java b/src/main/java/app/mealsmadeeasy/api/image/ImageController.java index 05a0ef8..4649df2 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/ImageController.java +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageController.java @@ -5,12 +5,16 @@ import app.mealsmadeeasy.api.image.converter.ImageToViewConverter; import app.mealsmadeeasy.api.image.converter.ImageUpdateBodyToSpecConverter; import app.mealsmadeeasy.api.image.spec.ImageCreateSpec; import app.mealsmadeeasy.api.image.view.ImageView; +import app.mealsmadeeasy.api.sliceview.SliceView; +import app.mealsmadeeasy.api.sliceview.SliceViewService; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserService; import app.mealsmadeeasy.api.util.AccessDeniedView; import app.mealsmadeeasy.api.util.ResourceExistsView; import lombok.RequiredArgsConstructor; import org.springframework.core.io.InputStreamResource; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -34,6 +38,7 @@ public class ImageController { private final UserService userService; private final ImageToViewConverter imageToViewConverter; private final ImageUpdateBodyToSpecConverter imageUpdateBodyToSpecConverter; + private final SliceViewService sliceViewService; @ExceptionHandler public ResponseEntity onAccessDenied(AccessDeniedException e) { @@ -48,6 +53,19 @@ public class ImageController { } } + @GetMapping + public ResponseEntity> getOwnedImages( + @AuthenticationPrincipal User principal, + Pageable pageable + ) { + final Slice images = this.imageService.getOwnedImages(principal, pageable); + final Slice imageViews = images.map(image -> + this.imageToViewConverter.convert(image, principal, false) + ); + final int count = this.imageService.countOwnedImages(principal); + return ResponseEntity.ok(this.sliceViewService.getSliceView(imageViews, count)); + } + @GetMapping("/{username}/{filename}") public ResponseEntity getImage( @AuthenticationPrincipal User principal, diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageRepository.java b/src/main/java/app/mealsmadeeasy/api/image/ImageRepository.java index 9c317b1..b8835ef 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/ImageRepository.java +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageRepository.java @@ -1,6 +1,8 @@ package app.mealsmadeeasy.api.image; import app.mealsmadeeasy.api.user.User; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -20,4 +22,9 @@ public interface ImageRepository extends JpaRepository { @Query("SELECT image from Image image WHERE image.owner.username = ?1 AND image.userFilename = ?2") Optional findByOwnerUsernameAndFilename(String username, String filename); + @Query("SELECT i FROM Image i WHERE i.owner = ?1") + Slice findAllOwnedBy(User user, Pageable pageable); + + int countAllByOwner(User owner); + } diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageService.java b/src/main/java/app/mealsmadeeasy/api/image/ImageService.java index f96579b..5755f22 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/ImageService.java +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageService.java @@ -4,6 +4,8 @@ import app.mealsmadeeasy.api.image.spec.ImageCreateSpec; import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec; import app.mealsmadeeasy.api.user.User; import org.jetbrains.annotations.Nullable; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import java.io.IOException; import java.io.InputStream; @@ -20,6 +22,8 @@ public interface ImageService { InputStream getImageContent(Image image, @Nullable User viewer) throws IOException; List getImagesOwnedBy(User user); + Slice getOwnedImages(User user, Pageable pageable); + int countOwnedImages(User user); boolean exists(User viewer, String username, String filename); diff --git a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java index 9609384..58e2aa4 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java +++ b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java @@ -9,6 +9,8 @@ import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException; import app.mealsmadeeasy.api.util.NoSuchEntityWithUsernameAndFilenameException; import org.jetbrains.annotations.Nullable; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; @@ -196,6 +198,16 @@ public class S3ImageService implements ImageService { return new ArrayList<>(this.imageRepository.findAllByOwner(user)); } + @Override + public Slice getOwnedImages(User user, Pageable pageable) { + return this.imageRepository.findAllOwnedBy(user, pageable); + } + + @Override + public int countOwnedImages(User user) { + return this.imageRepository.countAllByOwner(user); + } + @Override @PreAuthorize("@imageSecurity.canSeeExists(#viewer, #username, #filename)") public boolean exists(User viewer, String username, String filename) { diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java index e80b6f8..1ee03e8 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java @@ -69,4 +69,9 @@ public interface RecipeRepository extends JpaRepository { ) List searchByEmbeddingAndIsPublic(float[] queryEmbedding, float similarity); + int countByIsPublicIsTrue(); + + @Query("SELECT count(r) FROM Recipe r WHERE ?1 MEMBER OF r.viewers OR r.isPublic IS TRUE") + int countViewableBy(User viewer); + } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java index 7366124..fc30ed4 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java @@ -114,6 +114,10 @@ public class RecipeService { return this.recipeRepository.findAllViewableBy(viewer, pageable); } + public int countViewableBy(User viewer) { + return this.recipeRepository.countViewableBy(viewer); + } + public List getByMinimumStars(long minimumStars, @Nullable User viewer) { return List.copyOf( this.recipeRepository.findAllViewableByStarsGreaterThanEqual(minimumStars, viewer) @@ -124,6 +128,10 @@ public class RecipeService { return this.recipeRepository.findAllByIsPublicIsTrue(pageable); } + public int countPublicRecipes() { + return this.recipeRepository.countByIsPublicIsTrue(); + } + public List aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) { final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt()); final List results; diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipesController.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipesController.java index eb5b490..4489e9e 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipesController.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipesController.java @@ -14,6 +14,7 @@ import app.mealsmadeeasy.api.recipe.star.RecipeStar; import app.mealsmadeeasy.api.recipe.star.RecipeStarService; import app.mealsmadeeasy.api.recipe.view.FullRecipeView; import app.mealsmadeeasy.api.recipe.view.RecipeInfoView; +import app.mealsmadeeasy.api.sliceview.SliceView; import app.mealsmadeeasy.api.sliceview.SliceViewService; import app.mealsmadeeasy.api.user.User; import com.fasterxml.jackson.databind.ObjectMapper; @@ -78,7 +79,7 @@ public class RecipesController { } @GetMapping - public ResponseEntity> getRecipeInfoViews( + public ResponseEntity> getRecipeInfoViews( Pageable pageable, @AuthenticationPrincipal User user ) { @@ -87,13 +88,15 @@ public class RecipesController { final Slice publicRecipeInfoViews = publicRecipes.map( recipe -> this.recipeToInfoViewConverter.convert(recipe, null) ); - return ResponseEntity.ok(this.sliceViewService.getSliceView(publicRecipeInfoViews)); + final int count = this.recipeService.countPublicRecipes(); + return ResponseEntity.ok(this.sliceViewService.getSliceView(publicRecipeInfoViews, count)); } else { final Slice recipes = this.recipeService.getViewableBy(pageable, user); final Slice recipeInfoViews = recipes.map( recipe -> this.recipeToInfoViewConverter.convert(recipe, user) ); - return ResponseEntity.ok(this.sliceViewService.getSliceView(recipeInfoViews)); + final int count = this.recipeService.countViewableBy(user); + return ResponseEntity.ok(this.sliceViewService.getSliceView(recipeInfoViews, count)); } } @@ -151,7 +154,7 @@ public class RecipesController { } @GetMapping("/{username}/{slug}/comments") - public ResponseEntity> getComments( + public ResponseEntity> getComments( @PathVariable String username, @PathVariable String slug, Pageable pageable, @@ -163,7 +166,8 @@ public class RecipesController { pageable, principal ); - return ResponseEntity.ok(this.sliceViewService.getSliceView(slice)); + final int count = this.recipeCommentService.countComments(username, slug); + return ResponseEntity.ok(this.sliceViewService.getSliceView(slice, count)); } @PostMapping("/{username}/{slug}/comments") diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentRepository.java b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentRepository.java index 94fc54e..9876090 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentRepository.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentRepository.java @@ -4,8 +4,12 @@ import app.mealsmadeeasy.api.recipe.Recipe; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface RecipeCommentRepository extends JpaRepository { void deleteAllByRecipe(Recipe recipe); Slice findAllByRecipe(Recipe recipe, Pageable pageable); + + @Query("SELECT count(rc) FROM RecipeComment rc WHERE rc.recipe.owner.username = ?1 AND rc.recipe.slug = ?2") + int countByUsernameAndSlug(String username, String slug); } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentService.java b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentService.java index 020f0ec..54cf2c6 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentService.java @@ -9,5 +9,6 @@ public interface RecipeCommentService { RecipeComment get(Integer commentId, User viewer) ; Slice getComments(String recipeUsername, String recipeSlug, Pageable pageable, User viewer); RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) ; - void delete(Integer commentId, User modifier) ; + void delete(Integer commentId, User modifier); + int countComments(String recipeUsername, String recipeSlug); } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentServiceImpl.java b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentServiceImpl.java index a9e2fb7..5a93f5e 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentServiceImpl.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentServiceImpl.java @@ -105,4 +105,9 @@ public class RecipeCommentServiceImpl implements RecipeCommentService { this.recipeCommentRepository.delete(entityToDelete); } + @Override + public int countComments(String recipeUsername, String recipeSlug) { + return this.recipeCommentRepository.countByUsernameAndSlug(recipeUsername, recipeSlug); + } + } diff --git a/src/main/java/app/mealsmadeeasy/api/sliceview/SliceView.java b/src/main/java/app/mealsmadeeasy/api/sliceview/SliceView.java new file mode 100644 index 0000000..a657832 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/sliceview/SliceView.java @@ -0,0 +1,9 @@ +package app.mealsmadeeasy.api.sliceview; + +import java.util.List; + +public record SliceView(List content, SliceViewMeta slice, Integer count) { + + public record SliceViewMeta(Integer size, Integer number, Boolean hasNext) {} + +} diff --git a/src/main/java/app/mealsmadeeasy/api/sliceview/SliceViewService.java b/src/main/java/app/mealsmadeeasy/api/sliceview/SliceViewService.java index 57394d4..affaa45 100644 --- a/src/main/java/app/mealsmadeeasy/api/sliceview/SliceViewService.java +++ b/src/main/java/app/mealsmadeeasy/api/sliceview/SliceViewService.java @@ -3,21 +3,16 @@ package app.mealsmadeeasy.api.sliceview; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; -import java.util.HashMap; -import java.util.Map; - @Service public class SliceViewService { - public Map getSliceView(Slice slice) { - final Map view = new HashMap<>(); - view.put("content", slice.getContent()); - final Map sliceInfo = new HashMap<>(); - sliceInfo.put("size", slice.getSize()); - sliceInfo.put("number", slice.getNumber()); - sliceInfo.put("hasNext", slice.hasNext()); - view.put("slice", sliceInfo); - return view; + public SliceView getSliceView(Slice slice, int count) { + final SliceView.SliceViewMeta meta = new SliceView.SliceViewMeta( + slice.getSize(), + slice.getNumber(), + slice.hasNext() + ); + return new SliceView<>(slice.getContent(), meta, count); } }