Recipe GET now working by ownerUsername and slug.

This commit is contained in:
Jesse Brault 2024-07-29 17:43:39 -05:00
parent 3d7d5d00f1
commit 57d2451be9
18 changed files with 136 additions and 37 deletions

View File

@ -39,6 +39,7 @@ public class RecipeControllerTests {
private Recipe createTestRecipe(User owner, boolean isPublic) {
final RecipeCreateSpec spec = new RecipeCreateSpec();
spec.setSlug("test-recipe");
spec.setTitle("Test Recipe");
spec.setRawText("# Hello, World!");
spec.setPublic(isPublic);
@ -50,12 +51,12 @@ public class RecipeControllerTests {
public void getRecipePageViewByIdPublicRecipeNoPrincipal() throws Exception {
final User owner = this.createTestUser("owner");
final Recipe recipe = this.createTestRecipe(owner, true);
this.mockMvc.perform(get("/recipes/{id}", recipe.getId()))
this.mockMvc.perform(get("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.slug").value(recipe.getSlug()))
.andExpect(jsonPath("$.title").value("Test Recipe"))
.andExpect(jsonPath("$.text").value("<h1>Hello, World!</h1>"))
.andExpect(jsonPath("$.ownerId").value(owner.getId()))
.andExpect(jsonPath("$.ownerUsername").value(owner.getUsername()))
.andExpect(jsonPath("$.starCount").value(0))
.andExpect(jsonPath("$.viewerCount").value(0));
@ -74,8 +75,8 @@ public class RecipeControllerTests {
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.content[0].id").value(recipe.getId()))
.andExpect(jsonPath("$.content[0].updated").exists())
.andExpect(jsonPath("$.content[0].slug").value(recipe.getSlug()))
.andExpect(jsonPath("$.content[0].title").value(recipe.getTitle()))
.andExpect(jsonPath("$.content[0].ownerId").value(owner.getId()))
.andExpect(jsonPath("$.content[0].ownerUsername").value(owner.getUsername()))
.andExpect(jsonPath("$.content[0].public").value(true))
.andExpect(jsonPath("$.content[0].starCount").value(0));

View File

@ -42,6 +42,7 @@ public class RecipeRepositoryTests {
@DirtiesContext
public void findsAllPublicRecipes() {
final RecipeEntity publicRecipe = new RecipeEntity();
publicRecipe.setSlug("public-recipe");
publicRecipe.setPublic(true);
publicRecipe.setOwner(this.getOwnerUser());
publicRecipe.setTitle("Public Recipe");
@ -56,6 +57,7 @@ public class RecipeRepositoryTests {
@DirtiesContext
public void doesNotFindNonPublicRecipe() {
final RecipeEntity nonPublicRecipe = new RecipeEntity();
nonPublicRecipe.setSlug("non-public-recipe");
nonPublicRecipe.setOwner(this.getOwnerUser());
nonPublicRecipe.setTitle("Non-Public Recipe");
nonPublicRecipe.setRawText("Hello, World!");
@ -69,6 +71,7 @@ public class RecipeRepositoryTests {
@DirtiesContext
public void findsAllForViewer() {
final RecipeEntity recipe = new RecipeEntity();
recipe.setSlug("test-recipe");
recipe.setOwner(this.getOwnerUser());
recipe.setTitle("Test Recipe");
recipe.setRawText("Hello, World!");
@ -89,6 +92,7 @@ public class RecipeRepositoryTests {
@DirtiesContext
public void doesNotIncludeNonViewable() {
final RecipeEntity recipe = new RecipeEntity();
recipe.setSlug("test-recipe");
recipe.setOwner(this.getOwnerUser());
recipe.setTitle("Test Recipe");
recipe.setRawText("Hello, World!");

View File

@ -43,11 +43,16 @@ public class RecipeServiceTests {
}
private Recipe createTestRecipe(@Nullable User owner) {
return this.createTestRecipe(owner, false);
return this.createTestRecipe(owner, false, null);
}
private Recipe createTestRecipe(@Nullable User owner, boolean isPublic) {
return this.createTestRecipe(owner, isPublic, null);
}
private Recipe createTestRecipe(@Nullable User owner, boolean isPublic, @Nullable String slug) {
final RecipeCreateSpec spec = new RecipeCreateSpec();
spec.setSlug(slug != null ? slug : "my-recipe");
spec.setTitle("My Recipe");
spec.setRawText("Hello!");
spec.setPublic(isPublic);
@ -88,6 +93,7 @@ public class RecipeServiceTests {
final Recipe recipe = this.createTestRecipe(owner, true);
final Recipe byId = this.recipeService.getById(recipe.getId(), null);
assertThat(byId.getId(), is(recipe.getId()));
assertThat(byId.getSlug(), is(recipe.getSlug()));
assertThat(byId.getTitle(), is("My Recipe"));
assertThat(byId.getRawText(), is("Hello!"));
assertThat(byId.isPublic(), is(true));
@ -167,9 +173,9 @@ public class RecipeServiceTests {
final User u0 = this.createTestUser("u0");
final User u1 = this.createTestUser("u1");
final Recipe r0 = this.createTestRecipe(owner, true);
final Recipe r1 = this.createTestRecipe(owner, true);
final Recipe r2 = this.createTestRecipe(owner, true);
final Recipe r0 = this.createTestRecipe(owner, true, "r0");
final Recipe r1 = this.createTestRecipe(owner, true, "r1");
final Recipe r2 = this.createTestRecipe(owner, true, "r2");
// r0.stars = 0, r1.stars = 1, r2.stars = 2
this.recipeStarService.create(r1.getId(), u0.getUsername());
@ -197,9 +203,9 @@ public class RecipeServiceTests {
final User u1 = this.createTestUser("u1");
final User viewer = this.createTestUser("recipeViewer");
Recipe r0 = this.createTestRecipe(owner); // not public
Recipe r1 = this.createTestRecipe(owner);
Recipe r2 = this.createTestRecipe(owner);
Recipe r0 = this.createTestRecipe(owner, false, "r0"); // not public
Recipe r1 = this.createTestRecipe(owner, false, "r1");
Recipe r2 = this.createTestRecipe(owner, false, "r2");
for (final User starer : List.of(u0, u1)) {
r0 = this.recipeService.addViewer(r0.getId(), owner, starer);
@ -243,8 +249,8 @@ public class RecipeServiceTests {
public void getPublicRecipes() {
final User owner = this.createTestUser("recipeOwner");
Recipe r0 = this.createTestRecipe(owner, true);
Recipe r1 = this.createTestRecipe(owner, true);
Recipe r0 = this.createTestRecipe(owner, true, "r0");
Recipe r1 = this.createTestRecipe(owner, true, "r1");
final List<Recipe> publicRecipes = this.recipeService.getPublicRecipes();
assertThat(publicRecipes.size(), is(2));
@ -279,6 +285,7 @@ public class RecipeServiceTests {
public void updateRawText() throws RecipeException {
final User owner = this.createTestUser("recipeOwner");
final RecipeCreateSpec createSpec = new RecipeCreateSpec();
createSpec.setSlug("my-recipe");
createSpec.setTitle("My Recipe");
createSpec.setRawText("# A Heading");
Recipe recipe = this.recipeService.create(owner, createSpec);

View File

@ -2,6 +2,7 @@ spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=sa
app.mealsmadeeasy.api.baseUrl=http://localhost:8080
app.mealsmadeeasy.api.security.access-token-lifetime=60
app.mealsmadeeasy.api.security.refresh-token-lifetime=120
app.mealsmadeeasy.api.minio.endpoint=http://localhost:9000

View File

@ -61,6 +61,7 @@ public class DevConfiguration {
logger.info("Created {}", obazdaImage);
final RecipeCreateSpec recipeCreateSpec = new RecipeCreateSpec();
recipeCreateSpec.setSlug("test-recipe");
recipeCreateSpec.setTitle("Test Recipe");
recipeCreateSpec.setRawText("Hello, World!");
recipeCreateSpec.setPublic(true);

View File

@ -13,6 +13,7 @@ public interface Recipe {
Long getId();
LocalDateTime getCreated();
@Nullable LocalDateTime getModified();
String getSlug();
String getTitle();
String getRawText();
User getOwner();

View File

@ -31,10 +31,14 @@ public class RecipeController {
));
}
@GetMapping("/{id}")
public ResponseEntity<FullRecipeView> getById(@PathVariable long id, @AuthenticationPrincipal User user)
@GetMapping("/{username}/{slug}")
public ResponseEntity<FullRecipeView> getById(
@PathVariable String username,
@PathVariable String slug,
@AuthenticationPrincipal User viewer
)
throws RecipeException {
return ResponseEntity.ok(this.recipeService.getFullViewById(id, user));
return ResponseEntity.ok(this.recipeService.getFullViewByUsernameAndSlug(username, slug, viewer));
}
@GetMapping

View File

@ -27,6 +27,9 @@ public final class RecipeEntity implements Recipe {
private LocalDateTime modified;
@Column(nullable = false, unique = true)
private String slug;
@Column(nullable = false)
private String title;
@ -86,6 +89,15 @@ public final class RecipeEntity implements Recipe {
this.modified = modified;
}
@Override
public String getSlug() {
return this.slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
@Override
public String getTitle() {
return this.title;

View File

@ -3,7 +3,7 @@ package app.mealsmadeeasy.api.recipe;
public class RecipeException extends Exception {
public enum Type {
INVALID_OWNER_USERNAME, INVALID_STAR, NOT_VIEWABLE, INVALID_COMMENT_ID, INVALID_ID
INVALID_OWNER_USERNAME, INVALID_STAR, NOT_VIEWABLE, INVALID_COMMENT_ID, INVALID_USERNAME_OR_SLUG, INVALID_ID
}
private final Type type;

View File

@ -18,6 +18,9 @@ public interface RecipeRepository extends JpaRepository<RecipeEntity, Long> {
List<RecipeEntity> findAllByOwner(UserEntity owner);
@Query("SELECT r from Recipe r WHERE r.owner.username = ?1 AND r.slug = ?2")
Optional<RecipeEntity> findByOwnerUsernameAndSlug(String ownerUsername, String slug);
@Query("SELECT r FROM Recipe r WHERE size(r.stars) >= ?1 AND (r.isPublic OR ?2 MEMBER OF r.viewers)")
List<RecipeEntity> findAllViewableByStarsGreaterThanEqual(long stars, UserEntity viewer);

View File

@ -7,5 +7,6 @@ public interface RecipeSecurity {
boolean isOwner(Recipe recipe, User user);
boolean isOwner(long recipeId, User user) throws RecipeException;
boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException;
boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException;
boolean isViewableBy(long recipeId, @Nullable User user) throws RecipeException;
}

View File

@ -56,6 +56,16 @@ public class RecipeSecurityImpl implements RecipeSecurity {
return false;
}
@Override
public boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException {
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
));
return this.isViewableBy(recipe, user);
}
@Override
public boolean isViewableBy(long recipeId, @Nullable User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException(

View File

@ -18,6 +18,7 @@ public interface RecipeService {
Recipe getById(long id, @Nullable User viewer) throws RecipeException;
Recipe getByIdWithStars(long id, @Nullable User viewer) throws RecipeException;
FullRecipeView getFullViewById(long id, @Nullable User viewer) throws RecipeException;
FullRecipeView getFullViewByUsernameAndSlug(String username, String slug, @Nullable User viewer) throws RecipeException;
Slice<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer);
List<Recipe> getByMinimumStars(long minimumStars, @Nullable User viewer);

View File

@ -53,6 +53,7 @@ public class RecipeServiceImpl implements RecipeService {
final RecipeEntity draft = new RecipeEntity();
draft.setCreated(LocalDateTime.now());
draft.setOwner((UserEntity) owner);
draft.setSlug(spec.getSlug());
draft.setTitle(spec.getTitle());
draft.setRawText(spec.getRawText());
draft.setMainImage((S3ImageEntity) spec.getMainImage());
@ -97,26 +98,44 @@ public class RecipeServiceImpl implements RecipeService {
return this.recipeRepository.getViewerCount(recipeId);
}
@Override
@PostAuthorize("@recipeSecurity.isViewableBy(#id, #viewer)")
public FullRecipeView getFullViewById(long id, @Nullable User viewer) throws RecipeException {
final RecipeEntity recipe = this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID, "No such Recipe for id: " + id
));
private FullRecipeView getFullView(RecipeEntity recipe) {
final FullRecipeView view = new FullRecipeView();
view.setId(recipe.getId());
view.setCreated(recipe.getCreated());
view.setModified(recipe.getModified());
view.setSlug(recipe.getSlug());
view.setTitle(recipe.getTitle());
view.setText(this.getRenderedMarkdown(recipe));
view.setOwnerId(recipe.getOwner().getId());
view.setOwnerUsername(recipe.getOwner().getUsername());
view.setStarCount(this.getStarCount(recipe));
view.setViewerCount(this.getViewerCount(recipe.getId()));
if (recipe.getMainImage() != null) {
view.setMainImage(this.imageService.toImageView(recipe.getMainImage()));
}
return view;
}
@Override
@PreAuthorize("@recipeSecurity.isViewableBy(#id, #viewer)")
public FullRecipeView getFullViewById(long id, @Nullable User viewer) throws RecipeException {
final RecipeEntity recipe = this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID, "No such Recipe for id: " + id
));
return this.getFullView(recipe);
}
@Override
@PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
public FullRecipeView getFullViewByUsernameAndSlug(String username, String slug, @Nullable User viewer) throws RecipeException {
final RecipeEntity 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);
}
@Override
public Slice<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer) {
return this.recipeRepository.findAllViewableBy((UserEntity) viewer, pageable).map(entity -> {
@ -127,12 +146,14 @@ public class RecipeServiceImpl implements RecipeService {
} else {
view.setUpdated(entity.getCreated());
}
view.setSlug(entity.getSlug());
view.setTitle(entity.getTitle());
view.setOwnerId(entity.getOwner().getId());
view.setOwnerUsername(entity.getOwner().getUsername());
view.setPublic(entity.isPublic());
view.setStarCount(this.getStarCount(entity));
if (entity.getMainImage() != null) {
view.setMainImage(this.imageService.toImageView(entity.getMainImage()));
}
return view;
});
}
@ -164,6 +185,10 @@ public class RecipeServiceImpl implements RecipeService {
public Recipe update(long id, RecipeUpdateSpec spec, User modifier) throws RecipeException {
final RecipeEntity entity = this.findRecipeEntity(id);
boolean didModify = false;
if (spec.getSlug() != null) {
entity.setSlug(spec.getSlug());
didModify = true;
}
if (spec.getTitle() != null) {
entity.setTitle(spec.getTitle());
didModify = true;

View File

@ -5,11 +5,20 @@ import org.jetbrains.annotations.Nullable;
public class RecipeCreateSpec {
private String slug;
private String title;
private String rawText;
private boolean isPublic;
private @Nullable Image mainImage;
public String getSlug() {
return this.slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getTitle() {
return this.title;
}

View File

@ -5,11 +5,20 @@ import org.jetbrains.annotations.Nullable;
public class RecipeUpdateSpec {
private @Nullable String slug;
private @Nullable String title;
private @Nullable String rawText;
private @Nullable Boolean isPublic;
private @Nullable Image mainImage;
public @Nullable String getSlug() {
return this.slug;
}
public void setSlug(@Nullable String slug) {
this.slug = slug;
}
public @Nullable String getTitle() {
return this.title;
}

View File

@ -10,6 +10,7 @@ public class FullRecipeView {
private long id;
private LocalDateTime created;
private LocalDateTime modified;
private String slug;
private String title;
private String text;
private long ownerId;
@ -42,6 +43,14 @@ public class FullRecipeView {
this.modified = modified;
}
public String getSlug() {
return this.slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getTitle() {
return this.title;
}

View File

@ -1,6 +1,7 @@
package app.mealsmadeeasy.api.recipe.view;
import app.mealsmadeeasy.api.image.view.ImageView;
import org.jetbrains.annotations.Nullable;
import java.time.LocalDateTime;
@ -8,12 +9,12 @@ public final class RecipeInfoView {
private long id;
private LocalDateTime updated;
private String slug;
private String title;
private long ownerId;
private String ownerUsername;
private boolean isPublic;
private int starCount;
private ImageView mainImage;
private @Nullable ImageView mainImage;
public long getId() {
return this.id;
@ -31,6 +32,14 @@ public final class RecipeInfoView {
this.updated = updated;
}
public String getSlug() {
return this.slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getTitle() {
return this.title;
}
@ -39,14 +48,6 @@ public final class RecipeInfoView {
this.title = title;
}
public long getOwnerId() {
return this.ownerId;
}
public void setOwnerId(long ownerId) {
this.ownerId = ownerId;
}
public String getOwnerUsername() {
return this.ownerUsername;
}
@ -71,11 +72,11 @@ public final class RecipeInfoView {
this.starCount = starCount;
}
public ImageView getMainImage() {
public @Nullable ImageView getMainImage() {
return this.mainImage;
}
public void setMainImage(ImageView mainImage) {
public void setMainImage(@Nullable ImageView mainImage) {
this.mainImage = mainImage;
}