From b952440047db469037771202353f9a0aa0beeae8 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Sun, 21 Dec 2025 17:17:37 -0600 Subject: [PATCH] Add recipe comment endpoints and associated logic. --- .../api/markdown/MarkdownService.java | 5 ++ .../api/markdown/MarkdownServiceImpl.java | 21 +++++ .../api/recipe/RecipeController.java | 54 ++++++++++-- .../api/recipe/RecipeServiceImpl.java | 20 ++--- .../api/recipe/comment/RecipeComment.java | 4 +- .../comment/RecipeCommentCreateBody.java | 15 ++++ .../comment/RecipeCommentCreateSpec.java | 15 ---- .../comment/RecipeCommentRepository.java | 3 + .../recipe/comment/RecipeCommentService.java | 7 +- .../comment/RecipeCommentServiceImpl.java | 48 ++++++++-- .../api/recipe/comment/RecipeCommentView.java | 88 +++++++++++++++++++ .../api/sliceview/SliceViewService.java | 22 +++++ 12 files changed, 255 insertions(+), 47 deletions(-) create mode 100644 src/main/java/app/mealsmadeeasy/api/markdown/MarkdownService.java create mode 100644 src/main/java/app/mealsmadeeasy/api/markdown/MarkdownServiceImpl.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentCreateBody.java delete mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentCreateSpec.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentView.java create mode 100644 src/main/java/app/mealsmadeeasy/api/sliceview/SliceViewService.java diff --git a/src/main/java/app/mealsmadeeasy/api/markdown/MarkdownService.java b/src/main/java/app/mealsmadeeasy/api/markdown/MarkdownService.java new file mode 100644 index 0000000..362a3e4 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/markdown/MarkdownService.java @@ -0,0 +1,5 @@ +package app.mealsmadeeasy.api.markdown; + +public interface MarkdownService { + String renderAndCleanMarkdown(String rawText); +} diff --git a/src/main/java/app/mealsmadeeasy/api/markdown/MarkdownServiceImpl.java b/src/main/java/app/mealsmadeeasy/api/markdown/MarkdownServiceImpl.java new file mode 100644 index 0000000..83b8aed --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/markdown/MarkdownServiceImpl.java @@ -0,0 +1,21 @@ +package app.mealsmadeeasy.api.markdown; + +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.jsoup.Jsoup; +import org.jsoup.safety.Safelist; +import org.springframework.stereotype.Service; + +@Service +public class MarkdownServiceImpl implements MarkdownService { + + @Override + public String renderAndCleanMarkdown(String rawText) { + final var parser = Parser.builder().build(); + final var node = parser.parse(rawText); + final var htmlRenderer = HtmlRenderer.builder().build(); + final String unsafeHtml = htmlRenderer.render(node); + return Jsoup.clean(unsafeHtml, Safelist.relaxed()); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeController.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeController.java index 2e11479..b10734a 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeController.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeController.java @@ -1,12 +1,17 @@ package app.mealsmadeeasy.api.recipe; import app.mealsmadeeasy.api.image.ImageException; +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.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 org.jetbrains.annotations.Nullable; import org.springframework.data.domain.Pageable; @@ -26,10 +31,19 @@ public class RecipeController { private final RecipeService recipeService; private final RecipeStarService recipeStarService; + private final RecipeCommentService recipeCommentService; + private final SliceViewService sliceViewService; - public RecipeController(RecipeService recipeService, RecipeStarService recipeStarService) { + public RecipeController( + RecipeService recipeService, + RecipeStarService recipeStarService, + RecipeCommentService recipeCommentService, + SliceViewService sliceViewService + ) { this.recipeService = recipeService; this.recipeStarService = recipeStarService; + this.recipeCommentService = recipeCommentService; + this.sliceViewService = sliceViewService; } @ExceptionHandler(RecipeException.class) @@ -87,13 +101,7 @@ public class RecipeController { @AuthenticationPrincipal User user ) { final Slice slice = this.recipeService.getInfoViewsViewableBy(pageable, user); - final Map view = new HashMap<>(); - view.put("content", slice.getContent()); - final Map sliceInfo = new HashMap<>(); - sliceInfo.put("size", slice.getSize()); - sliceInfo.put("number", slice.getNumber()); - view.put("slice", sliceInfo); - return ResponseEntity.ok(view); + return ResponseEntity.ok(this.sliceViewService.getSliceView(slice)); } @PostMapping("/{username}/{slug}/star") @@ -138,4 +146,34 @@ public class RecipeController { return ResponseEntity.noContent().build(); } + @GetMapping("/{username}/{slug}/comments") + public ResponseEntity> getComments( + @PathVariable String username, + @PathVariable String slug, + Pageable pageable, + @Nullable @AuthenticationPrincipal User principal + ) throws RecipeException { + final Slice slice = this.recipeCommentService.getComments( + username, + slug, + pageable, + principal + ); + return ResponseEntity.ok(this.sliceViewService.getSliceView(slice)); + } + + @PostMapping("/{username}/{slug}/comments") + public ResponseEntity addComment( + @PathVariable String username, + @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."); + } + final RecipeComment comment = this.recipeCommentService.create(username, slug, principal, body); + return ResponseEntity.ok(RecipeCommentView.from(comment, false)); + } + } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeServiceImpl.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeServiceImpl.java index a734e8a..ffcc56b 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeServiceImpl.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeServiceImpl.java @@ -5,6 +5,7 @@ import app.mealsmadeeasy.api.image.ImageException; import app.mealsmadeeasy.api.image.ImageService; import app.mealsmadeeasy.api.image.S3ImageEntity; import app.mealsmadeeasy.api.image.view.ImageView; +import app.mealsmadeeasy.api.markdown.MarkdownService; import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository; @@ -12,12 +13,8 @@ import app.mealsmadeeasy.api.recipe.view.FullRecipeView; import app.mealsmadeeasy.api.recipe.view.RecipeInfoView; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserEntity; -import org.commonmark.parser.Parser; -import org.commonmark.renderer.html.HtmlRenderer; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Nullable; -import org.jsoup.Jsoup; -import org.jsoup.safety.Safelist; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.access.AccessDeniedException; @@ -33,26 +30,21 @@ import java.util.Set; @Service public class RecipeServiceImpl implements RecipeService { - private static String renderAndCleanMarkdown(String rawText) { - final var parser = Parser.builder().build(); - final var node = parser.parse(rawText); - final var htmlRenderer = HtmlRenderer.builder().build(); - final String unsafeHtml = htmlRenderer.render(node); - return Jsoup.clean(unsafeHtml, Safelist.relaxed()); - } - private final RecipeRepository recipeRepository; private final RecipeStarRepository recipeStarRepository; private final ImageService imageService; + private final MarkdownService markdownService; public RecipeServiceImpl( RecipeRepository recipeRepository, RecipeStarRepository recipeStarRepository, - ImageService imageService + ImageService imageService, + MarkdownService markdownService ) { this.recipeRepository = recipeRepository; this.recipeStarRepository = recipeStarRepository; this.imageService = imageService; + this.markdownService = markdownService; } @Override @@ -103,7 +95,7 @@ public class RecipeServiceImpl implements RecipeService { private String getRenderedMarkdown(RecipeEntity entity) { if (entity.getCachedRenderedText() == null) { - entity.setCachedRenderedText(renderAndCleanMarkdown(entity.getRawText())); + entity.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(entity.getRawText())); entity = this.recipeRepository.save(entity); } return entity.getCachedRenderedText(); diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeComment.java b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeComment.java index eebaab7..91a6a23 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeComment.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeComment.java @@ -2,12 +2,14 @@ package app.mealsmadeeasy.api.recipe.comment; import app.mealsmadeeasy.api.recipe.Recipe; import app.mealsmadeeasy.api.user.User; +import org.jetbrains.annotations.Nullable; import java.time.LocalDateTime; public interface RecipeComment { + Long getId(); LocalDateTime getCreated(); - LocalDateTime getModified(); + @Nullable LocalDateTime getModified(); String getRawText(); User getOwner(); Recipe getRecipe(); diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentCreateBody.java b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentCreateBody.java new file mode 100644 index 0000000..cdbb8ea --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentCreateBody.java @@ -0,0 +1,15 @@ +package app.mealsmadeeasy.api.recipe.comment; + +public class RecipeCommentCreateBody { + + private String text; + + public String getText() { + return this.text; + } + + public void setText(String text) { + this.text = text; + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentCreateSpec.java b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentCreateSpec.java deleted file mode 100644 index 577e019..0000000 --- a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentCreateSpec.java +++ /dev/null @@ -1,15 +0,0 @@ -package app.mealsmadeeasy.api.recipe.comment; - -public class RecipeCommentCreateSpec { - - private String rawText; - - public String getRawText() { - return this.rawText; - } - - public void setRawText(String rawText) { - this.rawText = rawText; - } - -} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentRepository.java b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentRepository.java index 452b22f..d0bc09e 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentRepository.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentRepository.java @@ -1,8 +1,11 @@ package app.mealsmadeeasy.api.recipe.comment; import app.mealsmadeeasy.api.recipe.RecipeEntity; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; public interface RecipeCommentRepository extends JpaRepository { void deleteAllByRecipe(RecipeEntity recipe); + Slice findAllByRecipe(RecipeEntity recipe, Pageable pageable); } 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 a42a4a9..24ce1d3 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentService.java @@ -2,10 +2,15 @@ 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(long recipeId, User owner, RecipeCommentCreateSpec spec) throws RecipeException; + RecipeComment create(String recipeUsername, String recipeSlug, User owner, RecipeCommentCreateBody body) + throws RecipeException; RecipeComment get(long commentId, User viewer) throws RecipeException; + Slice getComments(String recipeUsername, String recipeSlug, Pageable pageable, User viewer) + throws RecipeException; RecipeComment update(long commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException; void delete(long commentId, User modifier) throws RecipeException; } 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 bfab2a5..10611cd 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentServiceImpl.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentServiceImpl.java @@ -1,11 +1,15 @@ package app.mealsmadeeasy.api.recipe.comment; +import app.mealsmadeeasy.api.markdown.MarkdownService; import app.mealsmadeeasy.api.recipe.RecipeEntity; import app.mealsmadeeasy.api.recipe.RecipeException; import app.mealsmadeeasy.api.recipe.RecipeRepository; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserEntity; +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; import java.time.LocalDateTime; @@ -17,25 +21,37 @@ public class RecipeCommentServiceImpl implements RecipeCommentService { private final RecipeCommentRepository recipeCommentRepository; private final RecipeRepository recipeRepository; + private final MarkdownService markdownService; public RecipeCommentServiceImpl( RecipeCommentRepository recipeCommentRepository, - RecipeRepository recipeRepository + RecipeRepository recipeRepository, + MarkdownService markdownService ) { this.recipeCommentRepository = recipeCommentRepository; this.recipeRepository = recipeRepository; + this.markdownService = markdownService; } @Override - public RecipeComment create(long recipeId, User owner, RecipeCommentCreateSpec spec) throws RecipeException { - requireNonNull(owner); + @PreAuthorize("@recipeSecurity.isViewableBy(#recipeUsername, #recipeSlug, #commenter)") + public RecipeComment create( + String recipeUsername, + String recipeSlug, + User commenter, + RecipeCommentCreateBody body + ) throws RecipeException { + requireNonNull(commenter); final RecipeCommentEntity draft = new RecipeCommentEntity(); draft.setCreated(LocalDateTime.now()); - draft.setRawText(spec.getRawText()); - draft.setOwner((UserEntity) owner); - final RecipeEntity recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, "No such Recipe for id: " + recipeId - )); + draft.setRawText(body.getText()); + draft.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(body.getText())); + draft.setOwner((UserEntity) commenter); + final RecipeEntity recipe = this.recipeRepository.findByOwnerUsernameAndSlug(recipeUsername, recipeSlug) + .orElseThrow(() -> new RecipeException( + RecipeException.Type.INVALID_USERNAME_OR_SLUG, + "Invalid username or slug: " + recipeUsername + "/" + recipeSlug + )); draft.setRecipe(recipe); return this.recipeCommentRepository.save(draft); } @@ -52,6 +68,22 @@ public class RecipeCommentServiceImpl implements RecipeCommentService { 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 { + final RecipeEntity recipe = this.recipeRepository.findByOwnerUsernameAndSlug(recipeUsername, recipeSlug).orElseThrow( + () -> new RecipeException( + RecipeException.Type.INVALID_USERNAME_OR_SLUG, + "No such Recipe for username/slug: " + recipeUsername + "/" + recipeSlug + ) + ); + final Slice commentEntities = this.recipeCommentRepository.findAllByRecipe(recipe, pageable); + return commentEntities.map(commentEntity -> RecipeCommentView.from( + commentEntity, + false + )); + } + @Override public RecipeComment update(long commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException { final RecipeCommentEntity entity = this.loadCommentEntity(commentId, viewer); diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentView.java b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentView.java new file mode 100644 index 0000000..8ec0340 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/comment/RecipeCommentView.java @@ -0,0 +1,88 @@ +package app.mealsmadeeasy.api.recipe.comment; + +import app.mealsmadeeasy.api.user.view.UserInfoView; +import org.jetbrains.annotations.Nullable; + +import java.time.LocalDateTime; + +public class RecipeCommentView { + + public static RecipeCommentView from(RecipeComment comment, boolean includeRawText) { + final RecipeCommentView view = new RecipeCommentView(); + view.setId(comment.getId()); + view.setCreated(comment.getCreated()); + view.setModified(comment.getModified()); + view.setText(((RecipeCommentEntity) comment).getCachedRenderedText()); + if (includeRawText) { + view.setRawText(comment.getRawText()); + } + view.setOwner(UserInfoView.from(comment.getOwner())); + view.setRecipeId(comment.getRecipe().getId()); + return view; + } + + private Long id; + private LocalDateTime created; + private @Nullable LocalDateTime modified; + private String text; + private @Nullable String rawText; + private UserInfoView owner; + private Long recipeId; + + public Long getId() { + return this.id; + } + + public void setId(Long id) { + this.id = id; + } + + public LocalDateTime getCreated() { + return this.created; + } + + public void setCreated(LocalDateTime created) { + this.created = created; + } + + public @Nullable LocalDateTime getModified() { + return this.modified; + } + + public void setModified(@Nullable LocalDateTime modified) { + this.modified = modified; + } + + public String getText() { + return this.text; + } + + public void setText(String text) { + this.text = text; + } + + public @Nullable String getRawText() { + return this.rawText; + } + + public void setRawText(@Nullable String rawText) { + this.rawText = rawText; + } + + public UserInfoView getOwner() { + return this.owner; + } + + public void setOwner(UserInfoView owner) { + this.owner = owner; + } + + public Long getRecipeId() { + return this.recipeId; + } + + public void setRecipeId(Long recipeId) { + this.recipeId = recipeId; + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/sliceview/SliceViewService.java b/src/main/java/app/mealsmadeeasy/api/sliceview/SliceViewService.java new file mode 100644 index 0000000..fd18d42 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/sliceview/SliceViewService.java @@ -0,0 +1,22 @@ +package app.mealsmadeeasy.api.sliceview; + +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class SliceViewService { + + public Map getSliceView(Slice slice) { + final Map view = new HashMap<>(); + view.put("content", slice.getContent()); + final Map sliceInfo = new HashMap<>(); + sliceInfo.put("size", slice.getSize()); + sliceInfo.put("number", slice.getNumber()); + view.put("slice", sliceInfo); + return view; + } + +}