Add recipe comment endpoints and associated logic.

This commit is contained in:
Jesse Brault 2025-12-21 17:17:37 -06:00
parent 3166f1dd5d
commit b952440047
12 changed files with 255 additions and 47 deletions

View File

@ -0,0 +1,5 @@
package app.mealsmadeeasy.api.markdown;
public interface MarkdownService {
String renderAndCleanMarkdown(String rawText);
}

View File

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

View File

@ -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<RecipeInfoView> slice = this.recipeService.getInfoViewsViewableBy(pageable, user);
final Map<String, Object> view = new HashMap<>();
view.put("content", slice.getContent());
final Map<String, Object> sliceInfo = new HashMap<>();
sliceInfo.put("size", slice.getSize());
sliceInfo.put("number", slice.getNumber());
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<Map<String, Object>> getComments(
@PathVariable String username,
@PathVariable String slug,
Pageable pageable,
@Nullable @AuthenticationPrincipal User principal
) throws RecipeException {
final Slice<RecipeCommentView> slice = this.recipeCommentService.getComments(
username,
slug,
pageable,
principal
);
return ResponseEntity.ok(this.sliceViewService.getSliceView(slice));
}
@PostMapping("/{username}/{slug}/comments")
public ResponseEntity<RecipeCommentView> 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));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<RecipeCommentEntity, Long> {
void deleteAllByRecipe(RecipeEntity recipe);
Slice<RecipeCommentEntity> findAllByRecipe(RecipeEntity recipe, Pageable pageable);
}

View File

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

View File

@ -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,24 +21,36 @@ 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<RecipeCommentView> 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<RecipeCommentEntity> 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);

View File

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

View File

@ -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<String, Object> getSliceView(Slice<?> slice) {
final Map<String, Object> view = new HashMap<>();
view.put("content", slice.getContent());
final Map<String, Object> sliceInfo = new HashMap<>();
sliceInfo.put("size", slice.getSize());
sliceInfo.put("number", slice.getNumber());
view.put("slice", sliceInfo);
return view;
}
}