MME-8 Add endpoints necessary for recipe main image selection. Refactor SliceView.

This commit is contained in:
Jesse Brault 2026-02-05 18:39:32 -06:00
parent 02c7e8887e
commit f9c1d41501
14 changed files with 155 additions and 29 deletions

View File

@ -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());
}
}

View File

@ -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())

View File

@ -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<AccessDeniedView> onAccessDenied(AccessDeniedException e) {
@ -48,6 +53,19 @@ public class ImageController {
}
}
@GetMapping
public ResponseEntity<SliceView<ImageView>> getOwnedImages(
@AuthenticationPrincipal User principal,
Pageable pageable
) {
final Slice<Image> images = this.imageService.getOwnedImages(principal, pageable);
final Slice<ImageView> 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<InputStreamResource> getImage(
@AuthenticationPrincipal User principal,

View File

@ -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<Image, Integer> {
@Query("SELECT image from Image image WHERE image.owner.username = ?1 AND image.userFilename = ?2")
Optional<Image> findByOwnerUsernameAndFilename(String username, String filename);
@Query("SELECT i FROM Image i WHERE i.owner = ?1")
Slice<Image> findAllOwnedBy(User user, Pageable pageable);
int countAllByOwner(User owner);
}

View File

@ -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<Image> getImagesOwnedBy(User user);
Slice<Image> getOwnedImages(User user, Pageable pageable);
int countOwnedImages(User user);
boolean exists(User viewer, String username, String filename);

View File

@ -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<Image> 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) {

View File

@ -69,4 +69,9 @@ public interface RecipeRepository extends JpaRepository<Recipe, Integer> {
)
List<Recipe> 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);
}

View File

@ -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<Recipe> 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<Recipe> aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) {
final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt());
final List<Recipe> results;

View File

@ -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<Map<String, Object>> getRecipeInfoViews(
public ResponseEntity<SliceView<RecipeInfoView>> getRecipeInfoViews(
Pageable pageable,
@AuthenticationPrincipal User user
) {
@ -87,13 +88,15 @@ public class RecipesController {
final Slice<RecipeInfoView> 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<Recipe> recipes = this.recipeService.getViewableBy(pageable, user);
final Slice<RecipeInfoView> 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<Map<String, Object>> getComments(
public ResponseEntity<SliceView<RecipeCommentView>> 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")

View File

@ -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<RecipeComment, Integer> {
void deleteAllByRecipe(Recipe recipe);
Slice<RecipeComment> 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);
}

View File

@ -10,4 +10,5 @@ public interface RecipeCommentService {
Slice<RecipeCommentView> getComments(String recipeUsername, String recipeSlug, Pageable pageable, User viewer);
RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) ;
void delete(Integer commentId, User modifier);
int countComments(String recipeUsername, String recipeSlug);
}

View File

@ -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);
}
}

View File

@ -0,0 +1,9 @@
package app.mealsmadeeasy.api.sliceview;
import java.util.List;
public record SliceView<T>(List<T> content, SliceViewMeta slice, Integer count) {
public record SliceViewMeta(Integer size, Integer number, Boolean hasNext) {}
}

View File

@ -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<String, Object> getSliceView(Slice<?> slice) {
final Map<String, Object> view = new HashMap<>();
view.put("content", slice.getContent());
final Map<String, Object> 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 <T> SliceView<T> getSliceView(Slice<T> slice, int count) {
final SliceView.SliceViewMeta meta = new SliceView.SliceViewMeta(
slice.getSize(),
slice.getNumber(),
slice.hasNext()
);
return new SliceView<>(slice.getContent(), meta, count);
}
}