From 0a83a032c8de0d7ab5bee037d605e1152dcfd9ed Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Wed, 21 Jan 2026 17:42:30 -0600 Subject: [PATCH] Refactoring: less reliance on entity-specific exceptions, more converters, etc. --- .../api/image/ImageControllerTests.java | 3 +- .../api/image/S3ImageServiceTests.java | 3 +- .../api/recipe/RecipeRepositoryTests.java | 5 +- .../api/recipe/RecipeServiceTests.java | 53 +---- .../recipe/star/RecipeStarServiceTests.java | 5 +- .../api/image/ImageController.java | 19 +- .../api/image/ImageException.java | 3 - .../api/image/ImageSecurityImpl.java | 5 +- .../mealsmadeeasy/api/image/ImageService.java | 9 +- .../api/image/S3ImageService.java | 47 ++--- .../image/converter/ImageToViewConverter.java | 54 ++++++ .../api/image/view/ImageView.java | 27 --- .../app/mealsmadeeasy/api/recipe/Recipe.java | 12 +- .../api/recipe/RecipeDraftsController.java | 39 ++-- .../api/recipe/RecipeException.java | 27 --- .../api/recipe/RecipeRepository.java | 2 +- .../api/recipe/RecipeSecurity.java | 48 ++--- .../api/recipe/RecipeService.java | 182 ++++-------------- .../api/recipe/RecipesController.java | 78 ++++---- .../recipe/comment/RecipeCommentService.java | 13 +- .../comment/RecipeCommentServiceImpl.java | 48 ++--- .../converter/RecipeDraftToViewConverter.java | 39 ++++ .../RecipeDraftUpdateBodyToSpecConverter.java | 3 +- .../converter/RecipeToFullViewConverter.java | 44 +++++ .../converter/RecipeToInfoViewConverter.java | 38 ++++ .../api/recipe/star/RecipeStarService.java | 7 +- .../recipe/star/RecipeStarServiceImpl.java | 7 +- .../api/recipe/view/RecipeDraftView.java | 24 +-- .../api/recipe/view/RecipeInfoView.java | 20 -- .../api/util/ExceptionHandlers.java | 26 ++- ...ntityWithUsernameAndFilenameException.java | 12 ++ ...uchEntityWithUsernameAndSlugException.java | 12 ++ 32 files changed, 428 insertions(+), 486 deletions(-) create mode 100644 src/main/java/app/mealsmadeeasy/api/image/converter/ImageToViewConverter.java delete mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/RecipeException.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftToViewConverter.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeToFullViewConverter.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeToInfoViewConverter.java create mode 100644 src/main/java/app/mealsmadeeasy/api/util/NoSuchEntityWithUsernameAndFilenameException.java create mode 100644 src/main/java/app/mealsmadeeasy/api/util/NoSuchEntityWithUsernameAndSlugException.java diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java index 1473168..cec4d2a 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java @@ -9,6 +9,7 @@ import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserCreateException; import app.mealsmadeeasy.api.user.UserService; +import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -393,7 +394,7 @@ public class ImageControllerTests { .header("Authorization", "Bearer " + accessToken) ) .andExpect(status().isNoContent()); - assertThrows(ImageException.class, () -> this.imageService.getById(image.getId(), owner)); + assertThrows(NoSuchEntityWithIdException.class, () -> this.imageService.getById(image.getId(), owner)); } @Test diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/image/S3ImageServiceTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/image/S3ImageServiceTests.java index e52a8af..0c3d1a1 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/image/S3ImageServiceTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/image/S3ImageServiceTests.java @@ -6,6 +6,7 @@ import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserCreateException; import app.mealsmadeeasy.api.user.UserService; +import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -261,7 +262,7 @@ public class S3ImageServiceTests { final User owner = this.seedUser(); final Image image = this.seedImage(owner); this.imageService.deleteImage(image, owner); - assertThrows(ImageException.class, () -> this.imageService.getById(image.getId(), owner)); + assertThrows(NoSuchEntityWithIdException.class, () -> this.imageService.getById(image.getId(), owner)); } } diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeRepositoryTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeRepositoryTests.java index aac33ad..7ac9a14 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeRepositoryTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeRepositoryTests.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Pageable; import java.time.OffsetDateTime; import java.util.HashSet; @@ -46,7 +47,7 @@ public class RecipeRepositoryTests { publicRecipe.setRawText("Hello, World!"); final Recipe savedRecipe = this.recipeRepository.save(publicRecipe); - final List publicRecipes = this.recipeRepository.findAllByIsPublicIsTrue(); + final List publicRecipes = this.recipeRepository.findAllByIsPublicIsTrue(Pageable.unpaged()).toList(); assertThat(publicRecipes).anyMatch(recipeEntity -> recipeEntity.getId().equals(savedRecipe.getId())); } @@ -60,7 +61,7 @@ public class RecipeRepositoryTests { nonPublicRecipe.setRawText("Hello, World!"); final Recipe savedRecipe = this.recipeRepository.save(nonPublicRecipe); - final List publicRecipes = this.recipeRepository.findAllByIsPublicIsTrue(); + final List publicRecipes = this.recipeRepository.findAllByIsPublicIsTrue(Pageable.unpaged()).toList(); assertThat(publicRecipes).noneMatch(recipeEntity -> recipeEntity.getId().equals(savedRecipe.getId())); } diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java index c244a15..a476f21 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java @@ -1,14 +1,13 @@ package app.mealsmadeeasy.api.recipe; import app.mealsmadeeasy.api.IntegrationTestsExtension; -import app.mealsmadeeasy.api.image.ImageException; import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; import app.mealsmadeeasy.api.recipe.star.RecipeStar; import app.mealsmadeeasy.api.recipe.star.RecipeStarService; -import app.mealsmadeeasy.api.recipe.view.RecipeInfoView; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserRepository; +import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException; import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -21,7 +20,6 @@ import org.springframework.security.access.AccessDeniedException; import java.util.List; import java.util.UUID; -import static app.mealsmadeeasy.api.recipe.ContainsRecipeInfoViewsForRecipesMatcher.containsRecipeInfoViewsForRecipes; import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; @@ -78,13 +76,6 @@ public class RecipeServiceTests { assertThat(recipe.getRawText(), is("Hello!")); } - @Test - public void createWithoutOwnerThrowsAccessDenied() { - assertThrows(AccessDeniedException.class, () -> this.recipeService.create( - null, RecipeCreateSpec.builder().build() - )); - } - @Test public void getByIdPublicNoViewerDoesNotThrow() { final User owner = this.seedUser(); @@ -93,7 +84,7 @@ public class RecipeServiceTests { } @Test - public void getByIdHasCorrectProperties() throws RecipeException { + public void getByIdHasCorrectProperties() { final User owner = this.seedUser(); final Recipe recipe = this.createTestRecipe(owner, true); final Recipe byId = this.recipeService.getById(recipe.getId(), null); @@ -154,7 +145,7 @@ public class RecipeServiceTests { } @Test - public void getByIdWithStarsOkayWhenPublicRecipeWithViewer() throws RecipeException, ImageException { + public void getByIdWithStarsOkayWhenPublicRecipeWithViewer() { final User owner = this.seedUser(); final User viewer = this.seedUser(); final Recipe notYetPublicRecipe = this.createTestRecipe(owner); @@ -195,7 +186,7 @@ public class RecipeServiceTests { } @Test - public void getByMinimumStarsOnlySomeViewable() throws RecipeException { + public void getByMinimumStarsOnlySomeViewable() { final User owner = this.seedUser(); final User u0 = this.seedUser(); final User u1 = this.seedUser(); @@ -245,7 +236,7 @@ public class RecipeServiceTests { Recipe r0 = this.createTestRecipe(owner, true); Recipe r1 = this.createTestRecipe(owner, true); - final List publicRecipes = this.recipeService.getPublicRecipes(); + final List publicRecipes = this.recipeService.getPublicRecipes(Pageable.unpaged()).toList(); assertThat(publicRecipes, containsRecipes(r0, r1)); } @@ -255,37 +246,13 @@ public class RecipeServiceTests { Recipe r0 = this.createTestRecipe(owner, true); Recipe r1 = this.createTestRecipe(owner, false); - final Slice viewableInfoViewsSlice = this.recipeService.getInfoViewsViewableBy( - Pageable.ofSize(20), - owner - ); - final List viewableInfos = viewableInfoViewsSlice.getContent(); - assertThat(viewableInfos, containsRecipeInfoViewsForRecipes(r0, r1)); + final Slice viewableInfoViewsSlice = this.recipeService.getViewableBy(Pageable.unpaged(), owner); + final List viewableInfos = viewableInfoViewsSlice.toList(); + assertThat(viewableInfos, containsRecipes(r0, r1)); } @Test - public void getRecipesViewableByUser() throws RecipeException { - final User owner = this.seedUser(); - final User viewer = this.seedUser(); - - Recipe r0 = this.createTestRecipe(owner); - r0 = this.recipeService.addViewer(r0.getId(), owner, viewer); - final List viewableRecipes = this.recipeService.getRecipesViewableBy(viewer); - assertThat(viewableRecipes.size(), is(1)); - assertThat(viewableRecipes, containsRecipes(r0)); - } - - @Test - public void getRecipesOwnedByUser() { - final User owner = this.seedUser(); - final Recipe r0 = this.createTestRecipe(owner); - final List ownedRecipes = this.recipeService.getRecipesOwnedBy(owner); - assertThat(ownedRecipes.size(), is(1)); - assertThat(ownedRecipes, containsRecipes(r0)); - } - - @Test - public void updateRawText() throws RecipeException, ImageException { + public void updateRawText() { final User owner = this.seedUser(); final RecipeCreateSpec createSpec = RecipeCreateSpec.builder() .slug(UUID.randomUUID().toString()) @@ -330,7 +297,7 @@ public class RecipeServiceTests { final User owner = this.seedUser(); final Recipe toDelete = this.createTestRecipe(owner); this.recipeService.deleteRecipe(toDelete.getId(), owner); - assertThrows(RecipeException.class, () -> this.recipeService.getById(toDelete.getId(), owner)); + assertThrows(NoSuchEntityWithIdException.class, () -> this.recipeService.getById(toDelete.getId(), owner)); } @Test diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/star/RecipeStarServiceTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/star/RecipeStarServiceTests.java index 1b69bfb..5361664 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/star/RecipeStarServiceTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/star/RecipeStarServiceTests.java @@ -2,7 +2,6 @@ package app.mealsmadeeasy.api.recipe.star; import app.mealsmadeeasy.api.IntegrationTestsExtension; import app.mealsmadeeasy.api.recipe.Recipe; -import app.mealsmadeeasy.api.recipe.RecipeException; import app.mealsmadeeasy.api.recipe.RecipeService; import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; import app.mealsmadeeasy.api.user.User; @@ -63,7 +62,6 @@ public class RecipeStarServiceTests { recipe.getSlug(), starer )); - //noinspection DataFlowIssue assertThat(star.getTimestamp(), is(notNullValue())); } @@ -76,12 +74,11 @@ public class RecipeStarServiceTests { recipe.getId(), starer.getId() )); - //noinspection DataFlowIssue assertThat(star.getTimestamp(), is(notNullValue())); } @Test - public void find() throws RecipeException { + public void find() { final User owner = this.seedUser(); final User starer = this.seedUser(); final Recipe recipe = this.seedRecipe(owner); diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageController.java b/src/main/java/app/mealsmadeeasy/api/image/ImageController.java index 88e5703..40a7bb4 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/ImageController.java +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageController.java @@ -1,12 +1,14 @@ package app.mealsmadeeasy.api.image; import app.mealsmadeeasy.api.image.body.ImageUpdateBody; +import app.mealsmadeeasy.api.image.converter.ImageToViewConverter; import app.mealsmadeeasy.api.image.spec.ImageCreateSpec; import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec; import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserService; import app.mealsmadeeasy.api.util.AccessDeniedView; +import lombok.RequiredArgsConstructor; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -24,15 +26,12 @@ import java.util.stream.Collectors; @RestController @RequestMapping("/images") +@RequiredArgsConstructor public class ImageController { private final ImageService imageService; private final UserService userService; - - public ImageController(ImageService imageService, UserService userService) { - this.imageService = imageService; - this.userService = userService; - } + private final ImageToViewConverter imageToViewConverter; private ImageUpdateSpec getImageUpdateSpec(ImageUpdateBody body) { final var builder = ImageUpdateSpec.builder() @@ -73,7 +72,7 @@ public class ImageController { @AuthenticationPrincipal User principal, @PathVariable String username, @PathVariable String filename - ) throws ImageException, IOException { + ) throws IOException { final User owner = this.userService.getUser(username); final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal); final InputStream imageInputStream = this.imageService.getImageContent(image, principal); @@ -110,7 +109,7 @@ public class ImageController { image.getSize(), specBuilder.build() ); - return ResponseEntity.status(201).body(this.imageService.toImageView(saved, principal)); + return ResponseEntity.status(201).body(this.imageToViewConverter.convert(saved, principal, true)); } @PostMapping("/{username}/{filename}") @@ -119,14 +118,14 @@ public class ImageController { @PathVariable String username, @PathVariable String filename, @RequestBody ImageUpdateBody body - ) throws ImageException { + ) { if (principal == null) { throw new AccessDeniedException("Must be logged in."); } final User owner = this.userService.getUser(username); final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal); final Image updated = this.imageService.update(image, principal, this.getImageUpdateSpec(body)); - return ResponseEntity.ok(this.imageService.toImageView(updated, principal)); + return ResponseEntity.ok(this.imageToViewConverter.convert(updated, principal, true)); } @DeleteMapping("/{username}/{filename}") @@ -134,7 +133,7 @@ public class ImageController { @AuthenticationPrincipal User principal, @PathVariable String username, @PathVariable String filename - ) throws ImageException, IOException { + ) throws IOException { if (principal == null) { throw new AccessDeniedException("Must be logged in."); } diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageException.java b/src/main/java/app/mealsmadeeasy/api/image/ImageException.java index 2bf5581..36bf1e1 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/ImageException.java +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageException.java @@ -3,9 +3,6 @@ package app.mealsmadeeasy.api.image; public class ImageException extends Exception { public enum Type { - INVALID_ID, - INVALID_USERNAME_OR_FILENAME, - IMAGE_NOT_FOUND, UNSUPPORTED_IMAGE_TYPE, } diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageSecurityImpl.java b/src/main/java/app/mealsmadeeasy/api/image/ImageSecurityImpl.java index 0991718..4630fbe 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/ImageSecurityImpl.java +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageSecurityImpl.java @@ -1,6 +1,7 @@ package app.mealsmadeeasy.api.image; import app.mealsmadeeasy.api.user.User; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.springframework.stereotype.Component; @@ -16,7 +17,7 @@ public class ImageSecurityImpl implements ImageSecurity { } @Override - public boolean isViewableBy(Image image, @Nullable User viewer) { + public boolean isViewableBy(@NotNull Image image, @Nullable User viewer) { if (image.getIsPublic()) { // public image return true; @@ -40,7 +41,7 @@ public class ImageSecurityImpl implements ImageSecurity { } @Override - public boolean isOwner(Image image, @Nullable User user) { + public boolean isOwner(@NotNull Image image, @Nullable User user) { return image.getOwner() != null && user != null && Objects.equals(image.getOwner().getId(), user.getId()); } diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageService.java b/src/main/java/app/mealsmadeeasy/api/image/ImageService.java index 55553b2..bc7112d 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/ImageService.java +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageService.java @@ -2,7 +2,6 @@ package app.mealsmadeeasy.api.image; import app.mealsmadeeasy.api.image.spec.ImageCreateSpec; import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec; -import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.user.User; import org.jetbrains.annotations.Nullable; @@ -15,9 +14,9 @@ public interface ImageService { Image create(User owner, String userFilename, InputStream inputStream, long objectSize, ImageCreateSpec infoSpec) throws IOException, ImageException; - Image getById(Integer id, @Nullable User viewer) throws ImageException; - Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException; - Image getByUsernameAndFilename(String username, String filename, User viewer) throws ImageException; + Image getById(Integer id, @Nullable User viewer); + Image getByOwnerAndFilename(User owner, String filename, User viewer); + Image getByUsernameAndFilename(String username, String filename, User viewer); InputStream getImageContent(Image image, @Nullable User viewer) throws IOException; List getImagesOwnedBy(User user); @@ -26,6 +25,4 @@ public interface ImageService { void deleteImage(Image image, User modifier) throws IOException; - ImageView toImageView(Image image, @Nullable User viewer); - } diff --git a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java index a7a6fd5..aecf79a 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java +++ b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java @@ -2,10 +2,11 @@ package app.mealsmadeeasy.api.image; import app.mealsmadeeasy.api.image.spec.ImageCreateSpec; import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec; -import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.s3.S3Manager; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.util.MimeTypeService; +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.security.access.prepost.PostAuthorize; @@ -27,20 +28,17 @@ public class S3ImageService implements ImageService { private final S3Manager s3Manager; private final ImageRepository imageRepository; private final String imageBucketName; - private final String baseUrl; private final MimeTypeService mimeTypeService; public S3ImageService( S3Manager s3Manager, ImageRepository imageRepository, @Value("${app.mealsmadeeasy.api.images.bucketName}") String imageBucketName, - @Value("${app.mealsmadeeasy.api.baseUrl}") String baseUrl, MimeTypeService mimeTypeService ) { this.s3Manager = s3Manager; this.imageRepository = imageRepository; this.imageBucketName = imageBucketName; - this.baseUrl = baseUrl; this.mimeTypeService = mimeTypeService; } @@ -164,42 +162,38 @@ public class S3ImageService implements ImageService { @Override @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)") - public Image getById(Integer id, @Nullable User viewer) throws ImageException { - return this.imageRepository.findById(id).orElseThrow(() -> new ImageException( - ImageException.Type.INVALID_ID, "No Image with id: " + id - )); + public Image getById(Integer id, @Nullable User viewer) { + return this.imageRepository.findById(id).orElseThrow(() -> new NoSuchEntityWithIdException(Image.class, id)); } @Override @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)") - public Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException { - return this.imageRepository.findByOwnerAndUserFilename((User) owner, filename) - .orElseThrow(() -> new ImageException( - ImageException.Type.IMAGE_NOT_FOUND, - "No such image for owner " + owner + " with filename " + filename + public Image getByOwnerAndFilename(User owner, String filename, User viewer) { + return this.imageRepository.findByOwnerAndUserFilename(owner, filename) + .orElseThrow(() -> new NoSuchEntityWithUsernameAndFilenameException( + Image.class, + owner.getUsername(), + filename )); } @Override @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)") - public Image getByUsernameAndFilename(String username, String filename, User viewer) throws ImageException { + public Image getByUsernameAndFilename(String username, String filename, User viewer) { return this.imageRepository.findByOwnerUsernameAndFilename(username, filename).orElseThrow( - () -> new ImageException( - ImageException.Type.INVALID_USERNAME_OR_FILENAME, - "No such Image for username " + username + " and filename " + filename - ) + () -> new NoSuchEntityWithUsernameAndFilenameException(Image.class, username, filename) ); } @Override @PreAuthorize("@imageSecurity.isViewableBy(#image, #viewer)") public InputStream getImageContent(Image image, User viewer) throws IOException { - return this.s3Manager.load(this.imageBucketName, ((Image) image).getObjectName()); + return this.s3Manager.load(this.imageBucketName, image.getObjectName()); } @Override public List getImagesOwnedBy(User user) { - return new ArrayList<>(this.imageRepository.findAllByOwner((User) user)); + return new ArrayList<>(this.imageRepository.findAllByOwner(user)); } @Override @@ -219,17 +213,4 @@ public class S3ImageService implements ImageService { this.s3Manager.delete("images", image.getObjectName()); } - private String getImageUrl(Image image) { - return this.baseUrl + "/images/" + image.getOwner().getUsername() + "/" + image.getUserFilename(); - } - - @Override - public ImageView toImageView(Image image, @Nullable User viewer) { - if (viewer != null && image.getOwner().getUsername().equals(viewer.getUsername())) { - return ImageView.from(image, this.getImageUrl(image), true); - } else { - return ImageView.from(image, this.getImageUrl(image), false); - } - } - } diff --git a/src/main/java/app/mealsmadeeasy/api/image/converter/ImageToViewConverter.java b/src/main/java/app/mealsmadeeasy/api/image/converter/ImageToViewConverter.java new file mode 100644 index 0000000..ffcdcbb --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/image/converter/ImageToViewConverter.java @@ -0,0 +1,54 @@ +package app.mealsmadeeasy.api.image.converter; + +import app.mealsmadeeasy.api.image.Image; +import app.mealsmadeeasy.api.image.view.ImageView; +import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.user.view.UserInfoView; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.stream.Collectors; + +@Component +public class ImageToViewConverter { + + private final String baseUrl; + + public ImageToViewConverter(@Value("${app.mealsmadeeasy.api.baseUrl}") String baseUrl) { + this.baseUrl = baseUrl; + } + + private String getImageUrl(Image image) { + return this.baseUrl + "/images/" + image.getOwner().getUsername() + "/" + image.getUserFilename(); + } + + @Contract("null, _, _ -> null") + public @Nullable ImageView convert(@Nullable Image image, @Nullable User viewer, boolean includeViewers) { + if (image == null) { + return null; + } + final var builder = ImageView.builder() + .url(this.getImageUrl(image)) + .created(image.getCreated()) + .modified(image.getModified()) + .filename(image.getUserFilename()) + .mimeType(image.getMimeType()) + .alt(image.getAlt()) + .caption(image.getCaption()) + .owner(UserInfoView.from(image.getOwner())) + .isPublic(image.getIsPublic()) + .height(image.getHeight()) + .width(image.getWidth()); + if (includeViewers) { + builder.viewers( + image.getViewers().stream() + .map(UserInfoView::from) + .collect(Collectors.toSet()) + ); + } + return builder.build(); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java b/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java index ae50c84..18d5f9e 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java +++ b/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java @@ -1,6 +1,5 @@ package app.mealsmadeeasy.api.image.view; -import app.mealsmadeeasy.api.image.Image; import app.mealsmadeeasy.api.user.view.UserInfoView; import lombok.Builder; import lombok.Value; @@ -8,35 +7,10 @@ import org.jetbrains.annotations.Nullable; import java.time.OffsetDateTime; import java.util.Set; -import java.util.stream.Collectors; @Value @Builder public class ImageView { - - public static ImageView from(Image image, String url, boolean includeViewers) { - final var builder = ImageView.builder() - .url(url) - .created(image.getCreated()) - .modified(image.getModified()) - .filename(image.getUserFilename()) - .mimeType(image.getMimeType()) - .alt(image.getAlt()) - .caption(image.getCaption()) - .owner(UserInfoView.from(image.getOwner())) - .isPublic(image.getIsPublic()) - .height(image.getHeight()) - .width(image.getWidth()); - if (includeViewers) { - builder.viewers( - image.getViewers().stream() - .map(UserInfoView::from) - .collect(Collectors.toSet()) - ); - } - return builder.build(); - } - String url; OffsetDateTime created; @Nullable OffsetDateTime modified; @@ -49,5 +23,4 @@ public class ImageView { @Nullable Integer height; @Nullable Integer width; @Nullable Set viewers; - } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java b/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java index 448c6cb..d2421e4 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java @@ -71,7 +71,7 @@ public class Recipe { joinColumns = @JoinColumn(name = "recipe_id"), inverseJoinColumns = @JoinColumn(name = "viewer_id") ) - private Set viewers = new HashSet<>(); + private Set viewers = new HashSet<>(); // todo: see if we can get rid of this init @ManyToOne @JoinColumn(name = "main_image_id") @@ -81,4 +81,14 @@ public class Recipe { @OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) private RecipeEmbedding embedding; + @PrePersist + public void prePersist() { + this.created = OffsetDateTime.now(); + } + + @PreUpdate + public void preUpdate() { + this.modified = OffsetDateTime.now(); + } + } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java index 01b7838..0483033 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java @@ -2,18 +2,16 @@ package app.mealsmadeeasy.api.recipe; import app.mealsmadeeasy.api.file.File; import app.mealsmadeeasy.api.file.FileService; -import app.mealsmadeeasy.api.image.ImageException; -import app.mealsmadeeasy.api.image.ImageService; -import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.recipe.body.RecipeDraftUpdateBody; +import app.mealsmadeeasy.api.recipe.converter.RecipeDraftToViewConverter; import app.mealsmadeeasy.api.recipe.converter.RecipeDraftUpdateBodyToSpecConverter; +import app.mealsmadeeasy.api.recipe.converter.RecipeToFullViewConverter; import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec; import app.mealsmadeeasy.api.recipe.view.FullRecipeView; import app.mealsmadeeasy.api.recipe.view.RecipeDraftView; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.util.MustBeLoggedInException; import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.Nullable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -31,14 +29,9 @@ public class RecipeDraftsController { private final RecipeService recipeService; private final FileService fileService; - private final ImageService imageService; private final RecipeDraftUpdateBodyToSpecConverter updateBodyToSpecConverter; - - private @Nullable ImageView getImageView(RecipeDraft recipeDraft, User viewer) { - return recipeDraft.getMainImage() != null - ? this.imageService.toImageView(recipeDraft.getMainImage(), viewer) - : null; - } + private final RecipeDraftToViewConverter draftToViewConverter; + private final RecipeToFullViewConverter recipeToFullViewConverter; @GetMapping public ResponseEntity> getAllDraftsForUser(@AuthenticationPrincipal User user) { @@ -46,10 +39,10 @@ public class RecipeDraftsController { throw new MustBeLoggedInException(); } final List recipeDrafts = this.recipeService.getDrafts(user); - return ResponseEntity.ok(recipeDrafts.stream().map(recipeDraft -> { - final @Nullable ImageView mainImage = this.getImageView(recipeDraft, user); - return RecipeDraftView.from(recipeDraft, mainImage); - }).toList()); + return ResponseEntity.ok(recipeDrafts.stream() + .map(recipeDraft -> this.draftToViewConverter.convert(recipeDraft, user)) + .toList() + ); } @GetMapping("/{id}") @@ -61,8 +54,7 @@ public class RecipeDraftsController { throw new MustBeLoggedInException(); } final RecipeDraft recipeDraft = this.recipeService.getDraftByIdWithViewer(id, viewer); - final @Nullable ImageView imageView = this.getImageView(recipeDraft, viewer); - return ResponseEntity.ok(RecipeDraftView.from(recipeDraft, imageView)); + return ResponseEntity.ok(this.draftToViewConverter.convert(recipeDraft, viewer)); } @PostMapping("/manual") @@ -71,8 +63,7 @@ public class RecipeDraftsController { throw new MustBeLoggedInException(); } final RecipeDraft recipeDraft = this.recipeService.createDraft(owner); - final ImageView imageView = this.getImageView(recipeDraft, owner); - return ResponseEntity.ok(RecipeDraftView.from(recipeDraft, imageView)); + return ResponseEntity.ok(this.draftToViewConverter.convert(recipeDraft, owner)); } @PostMapping("/ai") @@ -91,8 +82,7 @@ public class RecipeDraftsController { owner ); final RecipeDraft recipeDraft = this.recipeService.createAiDraft(file, owner); - final @Nullable ImageView mainImageView = this.getImageView(recipeDraft, owner); - return ResponseEntity.status(HttpStatus.CREATED).body(RecipeDraftView.from(recipeDraft, mainImageView)); + return ResponseEntity.status(HttpStatus.CREATED).body(this.draftToViewConverter.convert(recipeDraft, owner)); } @PutMapping("/{id}") @@ -100,14 +90,13 @@ public class RecipeDraftsController { @AuthenticationPrincipal User modifier, @PathVariable UUID id, @RequestBody RecipeDraftUpdateBody updateBody - ) throws ImageException { + ) { if (modifier == null) { throw new MustBeLoggedInException(); } final RecipeDraftUpdateSpec spec = this.updateBodyToSpecConverter.convert(updateBody, modifier); final RecipeDraft updated = this.recipeService.updateDraft(id, spec, modifier); - final @Nullable ImageView imageView = this.getImageView(updated, modifier); - return ResponseEntity.ok(RecipeDraftView.from(updated, imageView)); + return ResponseEntity.ok(this.draftToViewConverter.convert(updated, modifier)); } @DeleteMapping("/{id}") @@ -131,7 +120,7 @@ public class RecipeDraftsController { throw new MustBeLoggedInException(); } final Recipe recipe = this.recipeService.publishDraft(id, modifier); - final FullRecipeView view = this.recipeService.toFullRecipeView(recipe, false, modifier); + final FullRecipeView view = this.recipeToFullViewConverter.convert(recipe, false, modifier); return ResponseEntity.status(HttpStatus.CREATED).body(view); } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeException.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeException.java deleted file mode 100644 index 25c5c85..0000000 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeException.java +++ /dev/null @@ -1,27 +0,0 @@ -package app.mealsmadeeasy.api.recipe; - -public class RecipeException extends Exception { - - public enum Type { - INVALID_USERNAME_OR_SLUG, - INVALID_ID, - INVALID_COMMENT_ID - } - - private final Type type; - - public RecipeException(Type type, String message, Throwable cause) { - super(message, cause); - this.type = type; - } - - public RecipeException(Type type, String message) { - super(message); - this.type = type; - } - - public Type getType() { - return this.type; - } - -} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java index 0d68a44..18c9add 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java @@ -12,7 +12,7 @@ import java.util.Optional; public interface RecipeRepository extends JpaRepository { - List findAllByIsPublicIsTrue(); + Slice findAllByIsPublicIsTrue(Pageable pageable); List findAllByViewersContaining(User viewer); diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurity.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurity.java index 7f28f1f..427893f 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurity.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurity.java @@ -1,6 +1,8 @@ package app.mealsmadeeasy.api.recipe; import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException; +import app.mealsmadeeasy.api.util.NoSuchEntityWithUsernameAndSlugException; import org.jetbrains.annotations.Nullable; import org.springframework.stereotype.Component; @@ -19,25 +21,21 @@ public class RecipeSecurity { return recipe.getOwner() != null && recipe.getOwner().getId().equals(user.getId()); } - public boolean isOwner(Integer recipeId, User user) throws RecipeException { - final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, - "No such Recipe with id " + recipeId - )); - return this.isOwner(recipe, user); - } - - public boolean isOwner(String username, String slug, @Nullable User user) throws RecipeException { - final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow( - () -> new RecipeException( - RecipeException.Type.INVALID_USERNAME_OR_SLUG, - "No such Recipe for username " + username + " and slug " + slug - ) + public boolean isOwner(Integer recipeId, User user) { + final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow( + () -> new NoSuchEntityWithIdException(Recipe.class, recipeId) ); return this.isOwner(recipe, user); } - public boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException { + public boolean isOwner(String username, String slug, @Nullable User user) { + final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow( + () -> new NoSuchEntityWithUsernameAndSlugException(Recipe.class, username, slug) + ); + return this.isOwner(recipe, user); + } + + public boolean isViewableBy(Recipe recipe, @Nullable User user) { if (recipe.getIsPublic()) { // public recipe return true; @@ -50,9 +48,7 @@ public class RecipeSecurity { } else { // check if viewer final Recipe withViewers = this.recipeRepository.findByIdWithViewers(recipe.getId()) - .orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, "No such Recipe with id: " + recipe.getId() - )); + .orElseThrow(() -> new NoSuchEntityWithIdException(Recipe.class, recipe.getId())); for (final User viewer : withViewers.getViewers()) { if (viewer.getId() != null && viewer.getId().equals(user.getId())) { return true; @@ -63,20 +59,16 @@ public class RecipeSecurity { return false; } - public boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException { + public boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) { final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(ownerUsername, slug) - .orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_USERNAME_OR_SLUG, - "No such Recipe for username " + ownerUsername + " and slug: " + slug - )); + .orElseThrow(() -> new NoSuchEntityWithUsernameAndSlugException(Recipe.class, ownerUsername, slug)); return this.isViewableBy(recipe, user); } - public boolean isViewableBy(Integer recipeId, @Nullable User user) throws RecipeException { - final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, - "No such Recipe with id: " + recipeId - )); + public boolean isViewableBy(Integer recipeId, @Nullable User user) { + final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow( + () -> new NoSuchEntityWithIdException(Recipe.class, recipeId) + ); return this.isViewableBy(recipe, user); } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java index b410802..ae225c3 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java @@ -2,9 +2,7 @@ package app.mealsmadeeasy.api.recipe; import app.mealsmadeeasy.api.file.File; import app.mealsmadeeasy.api.image.Image; -import app.mealsmadeeasy.api.image.ImageException; import app.mealsmadeeasy.api.image.ImageService; -import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.job.JobService; import app.mealsmadeeasy.api.markdown.MarkdownService; import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody; @@ -14,18 +12,16 @@ import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository; -import app.mealsmadeeasy.api.recipe.view.FullRecipeView; -import app.mealsmadeeasy.api.recipe.view.RecipeInfoView; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException; +import app.mealsmadeeasy.api.util.NoSuchEntityWithUsernameAndSlugException; import jakarta.transaction.Transactional; -import org.jetbrains.annotations.ApiStatus; +import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Nullable; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; -import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; @@ -37,6 +33,7 @@ import java.util.Set; import java.util.UUID; @Service +@RequiredArgsConstructor public class RecipeService { private final RecipeRepository recipeRepository; @@ -47,28 +44,7 @@ public class RecipeService { private final RecipeDraftRepository recipeDraftRepository; private final JobService jobService; - public RecipeService( - RecipeRepository recipeRepository, - RecipeStarRepository recipeStarRepository, - ImageService imageService, - MarkdownService markdownService, - EmbeddingModel embeddingModel, - RecipeDraftRepository recipeDraftRepository, - JobService jobService - ) { - this.recipeRepository = recipeRepository; - this.recipeStarRepository = recipeStarRepository; - this.imageService = imageService; - this.markdownService = markdownService; - this.embeddingModel = embeddingModel; - this.recipeDraftRepository = recipeDraftRepository; - this.jobService = jobService; - } - - public Recipe create(@Nullable User owner, RecipeCreateSpec spec) { - if (owner == null) { - throw new AccessDeniedException("Must be logged in."); - } + public Recipe create(User owner, RecipeCreateSpec spec) { final Recipe draft = new Recipe(); draft.setCreated(OffsetDateTime.now()); draft.setOwner(owner); @@ -80,34 +56,33 @@ public class RecipeService { return this.recipeRepository.save(draft); } - private Recipe findRecipeEntity(Integer id) throws RecipeException { - return this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id - )); + private Recipe getById(Integer id) { + return this.recipeRepository.findById(id).orElseThrow(() -> new NoSuchEntityWithIdException(Recipe.class, id)); + } + + private Recipe getByUsernameAndSlug(String username, String slug) { + return this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow( + () -> new NoSuchEntityWithUsernameAndSlugException(Recipe.class, username, slug) + ); } @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)") - public Recipe getById(Integer id, @Nullable User viewer) throws RecipeException { - return this.findRecipeEntity(id); + public Recipe getById(Integer id, @Nullable User viewer) { + return this.getById(id); } @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)") - public Recipe getByIdWithStars(Integer id, @Nullable User viewer) throws RecipeException { - return this.recipeRepository.findByIdWithStars(id).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, - "No such Recipe with id: " + id - )); + public Recipe getByIdWithStars(Integer id, @Nullable User viewer) { + return this.recipeRepository.findByIdWithStars(id).orElseThrow( + () -> new NoSuchEntityWithIdException(Recipe.class, id) + ); } @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)") - public Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) throws RecipeException { - return this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_USERNAME_OR_SLUG, - "No such Recipe for username " + username + " and slug " + slug - )); + public Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) { + return this.getByUsernameAndSlug(username, slug); } - @ApiStatus.Internal public String getRenderedMarkdown(Recipe entity) { if (entity.getCachedRenderedText() == null) { entity.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(entity.getRawText())); @@ -116,69 +91,16 @@ public class RecipeService { return entity.getCachedRenderedText(); } - private int getStarCount(Recipe recipe) { + public int getStarCount(Recipe recipe) { return this.recipeRepository.getStarCount(recipe.getId()); } - private int getViewerCount(long recipeId) { - return this.recipeRepository.getViewerCount(recipeId); + public int getViewerCount(Recipe recipe) { + return this.recipeRepository.getViewerCount(recipe.getId()); } - @Contract("null, _ -> null") - private @Nullable ImageView getImageView(@Nullable Image image, @Nullable User viewer) { - if (image != null) { - return this.imageService.toImageView(image, viewer); - } else { - return null; - } - } - - private FullRecipeView getFullView(Recipe recipe, boolean includeRawText, @Nullable User viewer) { - return FullRecipeView.from( - recipe, - this.getRenderedMarkdown(recipe), - includeRawText, - this.getStarCount(recipe), - this.getViewerCount(recipe.getId()), - this.getImageView(recipe.getMainImage(), viewer) - ); - } - - private RecipeInfoView getInfoView(Recipe recipe, @Nullable User viewer) { - return RecipeInfoView.from( - recipe, - this.getStarCount(recipe), - this.getImageView(recipe.getMainImage(), viewer) - ); - } - - @PreAuthorize("@recipeSecurity.isViewableBy(#id, #viewer)") - public FullRecipeView getFullViewById(Integer id, @Nullable User viewer) throws RecipeException { - final Recipe recipe = this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, "No such Recipe for id: " + id - )); - return this.getFullView(recipe, false, viewer); - } - - @PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)") - public FullRecipeView getFullViewByUsernameAndSlug( - String username, - String slug, - boolean includeRawText, - @Nullable User viewer - ) throws RecipeException { - final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug) - .orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_USERNAME_OR_SLUG, - "No such Recipe for username " + username + " and slug: " + slug - )); - return this.getFullView(recipe, includeRawText, viewer); - } - - public Slice getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer) { - return this.recipeRepository.findAllViewableBy(viewer, pageable).map(recipe -> - this.getInfoView(recipe, viewer) - ); + public Slice getViewableBy(Pageable pageable, User viewer) { + return this.recipeRepository.findAllViewableBy(viewer, pageable); } public List getByMinimumStars(long minimumStars, @Nullable User viewer) { @@ -187,19 +109,11 @@ public class RecipeService { ); } - public List getPublicRecipes() { - return List.copyOf(this.recipeRepository.findAllByIsPublicIsTrue()); + public Slice getPublicRecipes(Pageable pageable) { + return this.recipeRepository.findAllByIsPublicIsTrue(pageable); } - public List getRecipesViewableBy(User viewer) { - return List.copyOf(this.recipeRepository.findAllByViewersContaining(viewer)); - } - - public List getRecipesOwnedBy(User owner) { - return List.copyOf(this.recipeRepository.findAllByOwner(owner)); - } - - public List aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) { + public List aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) { final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt()); final List results; if (viewer == null) { @@ -207,12 +121,10 @@ public class RecipeService { } else { results = this.recipeRepository.searchByEmbeddingAndViewableBy(queryEmbedding, 0.5f, viewer.getId()); } - return results.stream() - .map(recipeEntity -> this.getInfoView(recipeEntity, viewer)) - .toList(); + return results; } - private void prepareForUpdate(RecipeUpdateSpec spec, Recipe recipe, User modifier) throws ImageException { + private void prepareForUpdate(RecipeUpdateSpec spec, Recipe recipe, User modifier) { boolean didUpdate = false; if (spec.getTitle() != null) { recipe.setTitle(spec.getTitle()); @@ -258,23 +170,17 @@ public class RecipeService { } @PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)") - public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier) - throws RecipeException, ImageException { - final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(() -> - new RecipeException( - RecipeException.Type.INVALID_USERNAME_OR_SLUG, - "No such Recipe for username " + username + " and slug: " + slug - ) - ); + public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier) { + final Recipe recipe = this.getByUsernameAndSlug(username, slug); this.prepareForUpdate(spec, recipe, modifier); return this.recipeRepository.save(recipe); } @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") - public Recipe addViewer(Integer id, User modifier, User viewer) throws RecipeException { - final Recipe entity = this.recipeRepository.findByIdWithViewers(id).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id - )); + public Recipe addViewer(Integer id, User modifier, User viewer) { + final Recipe entity = this.recipeRepository.findByIdWithViewers(id).orElseThrow( + () -> new NoSuchEntityWithIdException(Recipe.class, id) + ); final Set viewers = new HashSet<>(entity.getViewers()); viewers.add(viewer); entity.setViewers(viewers); @@ -282,8 +188,8 @@ public class RecipeService { } @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") - public Recipe removeViewer(Integer id, User modifier, User viewer) throws RecipeException { - final Recipe entity = this.findRecipeEntity(id); + public Recipe removeViewer(Integer id, User modifier, User viewer) { + final Recipe entity = this.getById(id); final Set viewers = new HashSet<>(entity.getViewers()); viewers.remove(viewer); entity.setViewers(viewers); @@ -291,8 +197,8 @@ public class RecipeService { } @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") - public Recipe clearAllViewers(Integer id, User modifier) throws RecipeException { - final Recipe entity = this.findRecipeEntity(id); + public Recipe clearAllViewers(Integer id, User modifier) { + final Recipe entity = this.getById(id); entity.setViewers(new HashSet<>()); return this.recipeRepository.save(entity); } @@ -302,14 +208,6 @@ public class RecipeService { this.recipeRepository.deleteById(id); } - public FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer) { - return this.getFullView(recipe, includeRawText, viewer); - } - - public RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer) { - return this.getInfoView(recipe, viewer); - } - @PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)") @Contract("_, _, null -> null") public @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer) { diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipesController.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipesController.java index 6d172c7..6618801 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipesController.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipesController.java @@ -1,6 +1,5 @@ package app.mealsmadeeasy.api.recipe; -import app.mealsmadeeasy.api.image.ImageException; import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody; import app.mealsmadeeasy.api.recipe.body.RecipeSearchBody; import app.mealsmadeeasy.api.recipe.body.RecipeUpdateBody; @@ -8,15 +7,17 @@ import app.mealsmadeeasy.api.recipe.comment.RecipeComment; import app.mealsmadeeasy.api.recipe.comment.RecipeCommentCreateBody; import app.mealsmadeeasy.api.recipe.comment.RecipeCommentService; import app.mealsmadeeasy.api.recipe.comment.RecipeCommentView; +import app.mealsmadeeasy.api.recipe.converter.RecipeToFullViewConverter; +import app.mealsmadeeasy.api.recipe.converter.RecipeToInfoViewConverter; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; 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.RecipeExceptionView; import app.mealsmadeeasy.api.recipe.view.RecipeInfoView; import app.mealsmadeeasy.api.sliceview.SliceViewService; import app.mealsmadeeasy.api.user.User; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.Nullable; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -32,6 +33,7 @@ import java.util.Map; @RestController @RequestMapping("/recipes") +@RequiredArgsConstructor public class RecipesController { private final RecipeService recipeService; @@ -39,32 +41,8 @@ public class RecipesController { private final RecipeCommentService recipeCommentService; private final SliceViewService sliceViewService; private final ObjectMapper objectMapper; - - public RecipesController( - RecipeService recipeService, - RecipeStarService recipeStarService, - RecipeCommentService recipeCommentService, - SliceViewService sliceViewService, - ObjectMapper objectMapper - ) { - this.recipeService = recipeService; - this.recipeStarService = recipeStarService; - this.recipeCommentService = recipeCommentService; - this.sliceViewService = sliceViewService; - this.objectMapper = objectMapper; - } - - @ExceptionHandler(RecipeException.class) - public ResponseEntity onRecipeException(RecipeException recipeException) { - final HttpStatus status = switch (recipeException.getType()) { - case INVALID_ID, INVALID_USERNAME_OR_SLUG -> HttpStatus.NOT_FOUND; - case INVALID_COMMENT_ID -> HttpStatus.BAD_REQUEST; - }; - return ResponseEntity.status(status.value()).body(new RecipeExceptionView( - recipeException.getType().toString(), - recipeException.getMessage() - )); - } + private final RecipeToFullViewConverter recipeToFullViewConverter; + private final RecipeToInfoViewConverter recipeToInfoViewConverter; private Map getFullViewWrapper(String username, String slug, FullRecipeView view, @Nullable User viewer) { Map wrapper = new HashMap<>(); @@ -80,13 +58,9 @@ public class RecipesController { @PathVariable String slug, @RequestParam(defaultValue = "false") boolean includeRawText, @AuthenticationPrincipal User viewer - ) throws RecipeException { - final FullRecipeView view = this.recipeService.getFullViewByUsernameAndSlug( - username, - slug, - includeRawText, - viewer - ); + ) { + final Recipe recipe = this.recipeService.getByUsernameAndSlug(username, slug, viewer); + final FullRecipeView view = this.recipeToFullViewConverter.convert(recipe, includeRawText, viewer); return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, viewer)); } @@ -97,10 +71,10 @@ public class RecipesController { @RequestParam(defaultValue = "true") boolean includeRawText, @RequestBody RecipeUpdateBody updateBody, @AuthenticationPrincipal User principal - ) throws ImageException, RecipeException { + ) { final RecipeUpdateSpec spec = RecipeUpdateSpec.from(updateBody); final Recipe updated = this.recipeService.update(username, slug, spec, principal); - final FullRecipeView view = this.recipeService.toFullRecipeView(updated, includeRawText, principal); + final FullRecipeView view = this.recipeToFullViewConverter.convert(updated, includeRawText, principal); return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, principal)); } @@ -109,8 +83,19 @@ public class RecipesController { Pageable pageable, @AuthenticationPrincipal User user ) { - final Slice slice = this.recipeService.getInfoViewsViewableBy(pageable, user); - return ResponseEntity.ok(this.sliceViewService.getSliceView(slice)); + if (user == null) { + final Slice publicRecipes = this.recipeService.getPublicRecipes(pageable); + final Slice publicRecipeInfoViews = publicRecipes.map( + recipe -> this.recipeToInfoViewConverter.convert(recipe, null) + ); + return ResponseEntity.ok(this.sliceViewService.getSliceView(publicRecipeInfoViews)); + } 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)); + } } @PostMapping @@ -123,7 +108,10 @@ public class RecipesController { recipeSearchBody.getData(), RecipeAiSearchBody.class ); - final List results = this.recipeService.aiSearch(spec, user); + final List results = this.recipeService.aiSearch(spec, user) + .stream() + .map(recipe -> this.recipeToInfoViewConverter.convert(recipe, user)) + .toList(); return ResponseEntity.ok(Map.of("results", results)); } else { throw new IllegalArgumentException("Invalid recipeSearchBody type: " + recipeSearchBody.getType()); @@ -135,7 +123,7 @@ public class RecipesController { @PathVariable String username, @PathVariable String slug, @Nullable @AuthenticationPrincipal User principal - ) throws RecipeException { + ) { if (principal == null) { throw new AccessDeniedException("Must be logged in to star a recipe."); } @@ -147,7 +135,7 @@ public class RecipesController { @PathVariable String username, @PathVariable String slug, @Nullable @AuthenticationPrincipal User principal - ) throws RecipeException { + ) { if (principal == null) { throw new AccessDeniedException("Must be logged in to get a recipe star."); } @@ -164,7 +152,7 @@ public class RecipesController { @PathVariable String username, @PathVariable String slug, @Nullable @AuthenticationPrincipal User principal - ) throws RecipeException { + ) { if (principal == null) { throw new AccessDeniedException("Must be logged in to delete a recipe star."); } @@ -178,7 +166,7 @@ public class RecipesController { @PathVariable String slug, Pageable pageable, @Nullable @AuthenticationPrincipal User principal - ) throws RecipeException { + ) { final Slice slice = this.recipeCommentService.getComments( username, slug, @@ -194,7 +182,7 @@ public class RecipesController { @PathVariable String slug, @RequestBody RecipeCommentCreateBody body, @Nullable @AuthenticationPrincipal User principal - ) throws RecipeException { + ) { if (principal == null) { throw new AccessDeniedException("Must be logged in to comment on a recipe."); } 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 def8fd4..020f0ec 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentService.java @@ -1,16 +1,13 @@ package app.mealsmadeeasy.api.recipe.comment; -import app.mealsmadeeasy.api.recipe.RecipeException; import app.mealsmadeeasy.api.user.User; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; public interface RecipeCommentService { - RecipeComment create(String recipeUsername, String recipeSlug, User owner, RecipeCommentCreateBody body) - throws RecipeException; - RecipeComment get(Integer commentId, User viewer) throws RecipeException; - Slice getComments(String recipeUsername, String recipeSlug, Pageable pageable, User viewer) - throws RecipeException; - RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException; - void delete(Integer commentId, User modifier) throws RecipeException; + RecipeComment create(String recipeUsername, String recipeSlug, User owner, RecipeCommentCreateBody body); + 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) ; } 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 1c617af..a9e2fb7 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentServiceImpl.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentServiceImpl.java @@ -2,9 +2,10 @@ package app.mealsmadeeasy.api.recipe.comment; import app.mealsmadeeasy.api.markdown.MarkdownService; import app.mealsmadeeasy.api.recipe.Recipe; -import app.mealsmadeeasy.api.recipe.RecipeException; import app.mealsmadeeasy.api.recipe.RecipeRepository; import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException; +import app.mealsmadeeasy.api.util.NoSuchEntityWithUsernameAndSlugException; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.access.prepost.PostAuthorize; @@ -39,42 +40,43 @@ public class RecipeCommentServiceImpl implements RecipeCommentService { String recipeSlug, User commenter, RecipeCommentCreateBody body - ) throws RecipeException { + ) { requireNonNull(commenter); final RecipeComment draft = new RecipeComment(); draft.setCreated(OffsetDateTime.now()); draft.setRawText(body.getText()); draft.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(body.getText())); - draft.setOwner((User) commenter); + draft.setOwner(commenter); final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(recipeUsername, recipeSlug) - .orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_USERNAME_OR_SLUG, - "Invalid username or slug: " + recipeUsername + "/" + recipeSlug - )); + .orElseThrow( + () -> new NoSuchEntityWithUsernameAndSlugException(Recipe.class, recipeUsername, recipeSlug) + ); draft.setRecipe(recipe); return this.recipeCommentRepository.save(draft); } @PostAuthorize("@recipeSecurity.isViewableBy(returnObject.recipe, #viewer)") - private RecipeComment loadCommentEntity(Integer commentId, User viewer) throws RecipeException { - return this.recipeCommentRepository.findById(commentId).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_COMMENT_ID, "No such RecipeComment for id: " + commentId - )); + private RecipeComment loadCommentEntity(Integer commentId, User viewer) { + return this.recipeCommentRepository.findById(commentId).orElseThrow( + () -> new NoSuchEntityWithIdException(RecipeComment.class, commentId) + ); } @Override - public RecipeComment get(Integer commentId, User viewer) throws RecipeException { + public RecipeComment get(Integer commentId, User viewer) { return this.loadCommentEntity(commentId, viewer); } @Override @PreAuthorize("@recipeSecurity.isViewableBy(#recipeUsername, #recipeSlug, #viewer)") - public Slice getComments(String recipeUsername, String recipeSlug, Pageable pageable, User viewer) throws RecipeException { + public Slice getComments( + String recipeUsername, + String recipeSlug, + Pageable pageable, + User viewer + ) { final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(recipeUsername, recipeSlug).orElseThrow( - () -> new RecipeException( - RecipeException.Type.INVALID_USERNAME_OR_SLUG, - "No such Recipe for username/slug: " + recipeUsername + "/" + recipeSlug - ) + () -> new NoSuchEntityWithUsernameAndSlugException(Recipe.class, recipeUsername, recipeSlug) ); final Slice commentEntities = this.recipeCommentRepository.findAllByRecipe(recipe, pageable); return commentEntities.map(commentEntity -> RecipeCommentView.from( @@ -84,21 +86,21 @@ public class RecipeCommentServiceImpl implements RecipeCommentService { } @Override - public RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException { + public RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) { final RecipeComment entity = this.loadCommentEntity(commentId, viewer); entity.setRawText(spec.getRawText()); return this.recipeCommentRepository.save(entity); } @PostAuthorize("@recipeSecurity.isOwner(returnObject.recipe, #modifier)") - private RecipeComment loadForDelete(Integer commentId, User modifier) throws RecipeException { - return this.recipeCommentRepository.findById(commentId).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_COMMENT_ID, "No such RecipeComment for id: " + commentId - )); + private RecipeComment loadForDelete(Integer commentId, User modifier) { + return this.recipeCommentRepository.findById(commentId).orElseThrow(() -> + new NoSuchEntityWithIdException(RecipeComment.class, commentId) + ); } @Override - public void delete(Integer commentId, User modifier) throws RecipeException { + public void delete(Integer commentId, User modifier) { final RecipeComment entityToDelete = this.loadForDelete(commentId, modifier); this.recipeCommentRepository.delete(entityToDelete); } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftToViewConverter.java b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftToViewConverter.java new file mode 100644 index 0000000..431db10 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftToViewConverter.java @@ -0,0 +1,39 @@ +package app.mealsmadeeasy.api.recipe.converter; + +import app.mealsmadeeasy.api.image.converter.ImageToViewConverter; +import app.mealsmadeeasy.api.image.view.ImageView; +import app.mealsmadeeasy.api.recipe.RecipeDraft; +import app.mealsmadeeasy.api.recipe.view.RecipeDraftView; +import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.user.view.UserInfoView; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RecipeDraftToViewConverter { + + private final ImageToViewConverter imageToViewConverter; + + public RecipeDraftView convert(RecipeDraft recipeDraft, User viewer) { + final @Nullable ImageView mainImageView = recipeDraft.getMainImage() != null + ? this.imageToViewConverter.convert(recipeDraft.getMainImage(), viewer, false) + : null; + return RecipeDraftView.builder() + .id(recipeDraft.getId()) + .created(recipeDraft.getCreated()) + .modified(recipeDraft.getModified()) + .state(recipeDraft.getState()) + .slug(recipeDraft.getSlug()) + .preparationTime(recipeDraft.getPreparationTime()) + .cookingTime(recipeDraft.getCookingTime()) + .totalTime(recipeDraft.getTotalTime()) + .rawText(recipeDraft.getRawText()) + .ingredients(recipeDraft.getIngredients()) + .owner(UserInfoView.from(recipeDraft.getOwner())) + .mainImage(mainImageView) + .build(); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftUpdateBodyToSpecConverter.java b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftUpdateBodyToSpecConverter.java index bc6d93f..e685a3d 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftUpdateBodyToSpecConverter.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftUpdateBodyToSpecConverter.java @@ -1,7 +1,6 @@ package app.mealsmadeeasy.api.recipe.converter; import app.mealsmadeeasy.api.image.Image; -import app.mealsmadeeasy.api.image.ImageException; import app.mealsmadeeasy.api.image.ImageService; import app.mealsmadeeasy.api.recipe.body.RecipeDraftUpdateBody; import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec; @@ -15,7 +14,7 @@ public class RecipeDraftUpdateBodyToSpecConverter { private final ImageService imageService; - public RecipeDraftUpdateSpec convert(RecipeDraftUpdateBody body, User viewer) throws ImageException { + public RecipeDraftUpdateSpec convert(RecipeDraftUpdateBody body, User viewer) { final var b = RecipeDraftUpdateSpec.builder() .slug(body.slug()) .title(body.title()) diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeToFullViewConverter.java b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeToFullViewConverter.java new file mode 100644 index 0000000..ffb0c3c --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeToFullViewConverter.java @@ -0,0 +1,44 @@ +package app.mealsmadeeasy.api.recipe.converter; + +import app.mealsmadeeasy.api.image.converter.ImageToViewConverter; +import app.mealsmadeeasy.api.recipe.Recipe; +import app.mealsmadeeasy.api.recipe.RecipeService; +import app.mealsmadeeasy.api.recipe.view.FullRecipeView; +import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.user.view.UserInfoView; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RecipeToFullViewConverter { + + private final RecipeService recipeService; + private final ImageToViewConverter imageToViewConverter; + + public FullRecipeView convert(Recipe recipe, boolean includeRawText, @Nullable User viewer) { + final var b = FullRecipeView.builder() + .id(recipe.getId()) + .created(recipe.getCreated()) + .modified(recipe.getModified()) + .slug(recipe.getSlug()) + .title(recipe.getTitle()) + .preparationTime(recipe.getPreparationTime()) + .cookingTime(recipe.getCookingTime()) + .totalTime(recipe.getTotalTime()) + .text(this.recipeService.getRenderedMarkdown(recipe)) + .owner(UserInfoView.from(recipe.getOwner())) + .starCount(this.recipeService.getStarCount(recipe)) + .viewerCount(this.recipeService.getViewerCount(recipe)) + .isPublic(recipe.getIsPublic()); + if (recipe.getMainImage() != null) { + b.mainImage(this.imageToViewConverter.convert(recipe.getMainImage(), viewer, false)); + } + if (includeRawText) { + b.rawText(recipe.getRawText()); + } + return b.build(); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeToInfoViewConverter.java b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeToInfoViewConverter.java new file mode 100644 index 0000000..b46db70 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeToInfoViewConverter.java @@ -0,0 +1,38 @@ +package app.mealsmadeeasy.api.recipe.converter; + +import app.mealsmadeeasy.api.image.converter.ImageToViewConverter; +import app.mealsmadeeasy.api.recipe.Recipe; +import app.mealsmadeeasy.api.recipe.RecipeService; +import app.mealsmadeeasy.api.recipe.view.RecipeInfoView; +import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.user.view.UserInfoView; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RecipeToInfoViewConverter { + + private final ImageToViewConverter imageToViewConverter; + private final RecipeService recipeService; + + public RecipeInfoView convert(@NotNull Recipe recipe, @Nullable User viewer) { + return RecipeInfoView.builder() + .id(recipe.getId()) + .created(recipe.getCreated()) + .modified(recipe.getModified()) + .slug(recipe.getSlug()) + .title(recipe.getTitle()) + .preparationTime(recipe.getPreparationTime()) + .cookingTime(recipe.getCookingTime()) + .totalTime(recipe.getTotalTime()) + .owner(UserInfoView.from(recipe.getOwner())) + .isPublic(recipe.getIsPublic()) + .starCount(this.recipeService.getStarCount(recipe)) + .mainImage(this.imageToViewConverter.convert(recipe.getMainImage(), viewer, false)) + .build(); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/star/RecipeStarService.java b/src/main/java/app/mealsmadeeasy/api/recipe/star/RecipeStarService.java index 03e387d..43f3c18 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/star/RecipeStarService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/star/RecipeStarService.java @@ -1,16 +1,15 @@ package app.mealsmadeeasy.api.recipe.star; -import app.mealsmadeeasy.api.recipe.RecipeException; import app.mealsmadeeasy.api.user.User; import java.util.Optional; public interface RecipeStarService { RecipeStar create(Integer recipeId, Integer ownerId); - RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException; + RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer); - Optional find(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException; + Optional find(String recipeOwnerUsername, String recipeSlug, User starer); void delete(Integer recipeId, Integer ownerId); - void delete(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException; + void delete(String recipeOwnerUsername, String recipeSlug, User starer); } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/star/RecipeStarServiceImpl.java b/src/main/java/app/mealsmadeeasy/api/recipe/star/RecipeStarServiceImpl.java index 2a2162d..7d0cae7 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/star/RecipeStarServiceImpl.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/star/RecipeStarServiceImpl.java @@ -1,7 +1,6 @@ package app.mealsmadeeasy.api.recipe.star; import app.mealsmadeeasy.api.recipe.Recipe; -import app.mealsmadeeasy.api.recipe.RecipeException; import app.mealsmadeeasy.api.recipe.RecipeService; import app.mealsmadeeasy.api.user.User; import org.springframework.stereotype.Service; @@ -32,7 +31,7 @@ public class RecipeStarServiceImpl implements RecipeStarService { } @Override - public RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException { + public RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer) { final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer); final Optional existing = this.recipeStarRepository.findByRecipeIdAndOwnerId( recipe.getId(), @@ -45,7 +44,7 @@ public class RecipeStarServiceImpl implements RecipeStarService { } @Override - public Optional find(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException { + public Optional find(String recipeOwnerUsername, String recipeSlug, User starer) { final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer); return this.recipeStarRepository.findByRecipeIdAndOwnerId(recipe.getId(), starer.getId()); } @@ -56,7 +55,7 @@ public class RecipeStarServiceImpl implements RecipeStarService { } @Override - public void delete(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException { + public void delete(String recipeOwnerUsername, String recipeSlug, User starer) { final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer); this.delete(recipe.getId(), starer.getId()); } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeDraftView.java b/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeDraftView.java index 5a23bbb..4f08b7f 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeDraftView.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeDraftView.java @@ -25,26 +25,4 @@ public record RecipeDraftView( @Nullable List ingredients, UserInfoView owner, @Nullable ImageView mainImage -) { - - public static RecipeDraftView from( - RecipeDraft recipeDraft, - @Nullable ImageView mainImageView - ) { - return RecipeDraftView.builder() - .id(recipeDraft.getId()) - .created(recipeDraft.getCreated()) - .modified(recipeDraft.getModified()) - .state(recipeDraft.getState()) - .slug(recipeDraft.getSlug()) - .preparationTime(recipeDraft.getPreparationTime()) - .cookingTime(recipeDraft.getCookingTime()) - .totalTime(recipeDraft.getTotalTime()) - .rawText(recipeDraft.getRawText()) - .ingredients(recipeDraft.getIngredients()) - .owner(UserInfoView.from(recipeDraft.getOwner())) - .mainImage(mainImageView) - .build(); - } - -} +) {} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeInfoView.java b/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeInfoView.java index fe9c6c9..749989a 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeInfoView.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeInfoView.java @@ -1,7 +1,6 @@ package app.mealsmadeeasy.api.recipe.view; import app.mealsmadeeasy.api.image.view.ImageView; -import app.mealsmadeeasy.api.recipe.Recipe; import app.mealsmadeeasy.api.user.view.UserInfoView; import lombok.Builder; import lombok.Value; @@ -12,24 +11,6 @@ import java.time.OffsetDateTime; @Value @Builder public class RecipeInfoView { - - public static RecipeInfoView from(Recipe recipe, int starCount, @Nullable ImageView mainImage) { - return RecipeInfoView.builder() - .id(recipe.getId()) - .created(recipe.getCreated()) - .modified(recipe.getModified()) - .slug(recipe.getSlug()) - .title(recipe.getTitle()) - .preparationTime(recipe.getPreparationTime()) - .cookingTime(recipe.getCookingTime()) - .totalTime(recipe.getTotalTime()) - .owner(UserInfoView.from(recipe.getOwner())) - .isPublic(recipe.getIsPublic()) - .starCount(starCount) - .mainImage(mainImage) - .build(); - } - Integer id; OffsetDateTime created; OffsetDateTime modified; @@ -42,5 +23,4 @@ public class RecipeInfoView { boolean isPublic; int starCount; @Nullable ImageView mainImage; - } diff --git a/src/main/java/app/mealsmadeeasy/api/util/ExceptionHandlers.java b/src/main/java/app/mealsmadeeasy/api/util/ExceptionHandlers.java index 5b70046..91b07eb 100644 --- a/src/main/java/app/mealsmadeeasy/api/util/ExceptionHandlers.java +++ b/src/main/java/app/mealsmadeeasy/api/util/ExceptionHandlers.java @@ -18,7 +18,7 @@ public class ExceptionHandlers { .body(new NoSuchEntityWithIdExceptionView<>( e.getEntityType().getSimpleName(), e.getId(), - "Could not find " + e.getEntityType().getSimpleName() + " with id " + e.getId() + String.format("No such entity %s with id %s", e.getEntityType().getSimpleName(), e.getId()) )); } @@ -32,4 +32,28 @@ public class ExceptionHandlers { .body(new MustBeLoggedInExceptionView(e.getMessage())); } + public record NoSuchEntityWithUsernameAndSlugExceptionView( + String entityName, + String username, + String slug, + String message + ) {} + + @ExceptionHandler(NoSuchEntityWithUsernameAndSlugException.class) + public ResponseEntity handleNoSuchEntityWithUsernameAndSlugException( + NoSuchEntityWithUsernameAndSlugException e + ) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new NoSuchEntityWithUsernameAndSlugExceptionView( + e.getEntityType().getSimpleName(), + e.getUsername(), + e.getSlug(), + String.format( + "No such entity %s for username %s and slug %s", + e.getEntityType().getSimpleName(), + e.getUsername(), + e.getSlug() + ) + )); + } + } diff --git a/src/main/java/app/mealsmadeeasy/api/util/NoSuchEntityWithUsernameAndFilenameException.java b/src/main/java/app/mealsmadeeasy/api/util/NoSuchEntityWithUsernameAndFilenameException.java new file mode 100644 index 0000000..df9bd6e --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/util/NoSuchEntityWithUsernameAndFilenameException.java @@ -0,0 +1,12 @@ +package app.mealsmadeeasy.api.util; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class NoSuchEntityWithUsernameAndFilenameException extends RuntimeException { + private final Class entityType; + private final String username; + private final String filename; +} diff --git a/src/main/java/app/mealsmadeeasy/api/util/NoSuchEntityWithUsernameAndSlugException.java b/src/main/java/app/mealsmadeeasy/api/util/NoSuchEntityWithUsernameAndSlugException.java new file mode 100644 index 0000000..7524b86 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/util/NoSuchEntityWithUsernameAndSlugException.java @@ -0,0 +1,12 @@ +package app.mealsmadeeasy.api.util; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class NoSuchEntityWithUsernameAndSlugException extends RuntimeException { + private final Class entityType; + private final String username; + private final String slug; +}