Compare commits

..

No commits in common. "54118d597e6597f362974f79fc49988fb3d4ea14" and "db9e9eca07056af8ccd8044b25af94148fcd4d9a" have entirely different histories.

43 changed files with 687 additions and 657 deletions

View File

@ -9,7 +9,6 @@ import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException; import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService; import app.mealsmadeeasy.api.user.UserService;
import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@ -394,7 +393,7 @@ public class ImageControllerTests {
.header("Authorization", "Bearer " + accessToken) .header("Authorization", "Bearer " + accessToken)
) )
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
assertThrows(NoSuchEntityWithIdException.class, () -> this.imageService.getById(image.getId(), owner)); assertThrows(ImageException.class, () -> this.imageService.getById(image.getId(), owner));
} }
@Test @Test

View File

@ -6,7 +6,6 @@ import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException; import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService; import app.mealsmadeeasy.api.user.UserService;
import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -262,7 +261,7 @@ public class S3ImageServiceTests {
final User owner = this.seedUser(); final User owner = this.seedUser();
final Image image = this.seedImage(owner); final Image image = this.seedImage(owner);
this.imageService.deleteImage(image, owner); this.imageService.deleteImage(image, owner);
assertThrows(NoSuchEntityWithIdException.class, () -> this.imageService.getById(image.getId(), owner)); assertThrows(ImageException.class, () -> this.imageService.getById(image.getId(), owner));
} }
} }

View File

@ -1,36 +1,23 @@
package app.mealsmadeeasy.api.recipe; package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.IntegrationTestsExtension; import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.ai.InferenceService;
import app.mealsmadeeasy.api.ai.OcrService;
import app.mealsmadeeasy.api.auth.AuthService; import app.mealsmadeeasy.api.auth.AuthService;
import app.mealsmadeeasy.api.auth.LoginException; import app.mealsmadeeasy.api.auth.LoginException;
import app.mealsmadeeasy.api.recipe.body.RecipeDraftUpdateBody;
import app.mealsmadeeasy.api.recipe.view.RecipeDraftView;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException; import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService; import app.mealsmadeeasy.api.user.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.util.MimeTypeUtils;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -51,9 +38,6 @@ public class RecipeDraftsControllerIntegrationTests {
@Autowired @Autowired
private RecipeService recipeService; private RecipeService recipeService;
@Autowired
private ObjectMapper objectMapper;
private static final String TEST_PASSWORD = "test"; private static final String TEST_PASSWORD = "test";
private User seedUser() { private User seedUser() {
@ -130,118 +114,4 @@ public class RecipeDraftsControllerIntegrationTests {
.andExpect(jsonPath("$", hasSize(2))); .andExpect(jsonPath("$", hasSize(2)));
} }
@Test
public void manualDraft_returnsDraft() throws Exception {
final User owner = this.seedUser();
this.mockMvc.perform(
post("/recipe-drafts/manual")
.header("Authorization", "Bearer " + this.getAccessToken(owner))
)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.created", is(notNullValue())))
.andExpect(jsonPath("$.state", is(RecipeDraft.State.ENTER_DATA.toString())))
.andExpect(jsonPath("$.owner.id", is(owner.getId())));
}
@Nested
public class AiDraftTestsWithMocks {
@MockitoBean
private OcrService ocrService;
@MockitoBean
private InferenceService inferenceService;
@Test
public void whenAiDraft_returnsDraft() throws Exception {
final User owner = seedUser();
final MockMultipartFile sourceFile = new MockMultipartFile(
"sourceFile",
"recipe.jpeg",
MimeTypeUtils.IMAGE_JPEG_VALUE,
AiDraftTestsWithMocks.class.getResourceAsStream("recipe.jpeg")
);
mockMvc.perform(
multipart("/recipe-drafts/ai")
.file(sourceFile)
.param("sourceFileName", sourceFile.getOriginalFilename())
.header("Authorization", "Bearer " + getAccessToken(owner))
)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.created", is(notNullValue())))
.andExpect(jsonPath("$.state", is(RecipeDraft.State.INFER.toString())))
.andExpect(jsonPath("$.owner.id", is(owner.getId())));
}
}
@Test
public void pollAiDraft_returnsDraft() throws Exception {
final User owner = this.seedUser();
final MockMultipartFile sourceFile = new MockMultipartFile(
"sourceFile",
"recipe.jpeg",
MimeTypeUtils.IMAGE_JPEG_VALUE,
RecipeDraftsControllerIntegrationTests.class.getResourceAsStream("recipe.jpeg")
);
final MvcResult multipartResult = this.mockMvc.perform(
multipart("/recipe-drafts/ai")
.file(sourceFile)
.param("sourceFileName", sourceFile.getOriginalFilename())
.header("Authorization", "Bearer " + this.getAccessToken(owner))
)
.andExpect(status().isCreated())
.andReturn();
final String rawContent = multipartResult.getResponse().getContentAsString();
final RecipeDraftView recipeDraftView = this.objectMapper.readValue(rawContent, RecipeDraftView.class);
await().atMost(60, TimeUnit.SECONDS).untilAsserted(() -> {
this.mockMvc.perform(
get("/recipe-drafts/{id}", recipeDraftView.id())
.header("Authorization", "Bearer " + this.getAccessToken(owner))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.state", is(RecipeDraft.State.ENTER_DATA.toString())));
});
}
@Test
public void whenUpdate_returnsUpdated() throws Exception {
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
final RecipeDraftUpdateBody updateBody = RecipeDraftUpdateBody.builder()
.title("Test Title")
.slug("test-slug")
.preparationTime(15)
.cookingTime(30)
.totalTime(45)
.rawText("Hello, World!")
.ingredients(List.of(
RecipeDraftUpdateBody.IngredientDraftUpdateBody.builder()
.amount("1 Unit")
.name("Test Ingredient")
.notes("Separated")
.build()
))
.build();
this.mockMvc.perform(
put("/recipe-drafts/{id}", recipeDraft.getId())
.header("Authorization", "Bearer " + this.getAccessToken(owner))
.header("Content-Type", "application/json")
.content(this.objectMapper.writeValueAsString(updateBody))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.title", is("Test Title")))
.andExpect(jsonPath("$.slug", is("test-slug")))
.andExpect(jsonPath("$.preparationTime", is(15)))
.andExpect(jsonPath("$.cookingTime", is(30)))
.andExpect(jsonPath("$.totalTime", is(45)))
.andExpect(jsonPath("$.rawText", is("Hello, World!")))
.andExpect(jsonPath("$.ingredients", hasSize(1)))
.andExpect(jsonPath("$.ingredients[0].amount", is("1 Unit")))
.andExpect(jsonPath("$.ingredients[0].name", is("Test Ingredient")))
.andExpect(jsonPath("$.ingredients[0].notes", is("Separated")));
}
} }

View File

@ -7,7 +7,6 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Pageable;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.HashSet; import java.util.HashSet;
@ -47,7 +46,7 @@ public class RecipeRepositoryTests {
publicRecipe.setRawText("Hello, World!"); publicRecipe.setRawText("Hello, World!");
final Recipe savedRecipe = this.recipeRepository.save(publicRecipe); final Recipe savedRecipe = this.recipeRepository.save(publicRecipe);
final List<Recipe> publicRecipes = this.recipeRepository.findAllByIsPublicIsTrue(Pageable.unpaged()).toList(); final List<Recipe> publicRecipes = this.recipeRepository.findAllByIsPublicIsTrue();
assertThat(publicRecipes).anyMatch(recipeEntity -> recipeEntity.getId().equals(savedRecipe.getId())); assertThat(publicRecipes).anyMatch(recipeEntity -> recipeEntity.getId().equals(savedRecipe.getId()));
} }
@ -61,7 +60,7 @@ public class RecipeRepositoryTests {
nonPublicRecipe.setRawText("Hello, World!"); nonPublicRecipe.setRawText("Hello, World!");
final Recipe savedRecipe = this.recipeRepository.save(nonPublicRecipe); final Recipe savedRecipe = this.recipeRepository.save(nonPublicRecipe);
final List<Recipe> publicRecipes = this.recipeRepository.findAllByIsPublicIsTrue(Pageable.unpaged()).toList(); final List<Recipe> publicRecipes = this.recipeRepository.findAllByIsPublicIsTrue();
assertThat(publicRecipes).noneMatch(recipeEntity -> recipeEntity.getId().equals(savedRecipe.getId())); assertThat(publicRecipes).noneMatch(recipeEntity -> recipeEntity.getId().equals(savedRecipe.getId()));
} }

View File

@ -1,13 +1,14 @@
package app.mealsmadeeasy.api.recipe; package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.IntegrationTestsExtension; import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStar; import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.recipe.star.RecipeStarService; 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.User;
import app.mealsmadeeasy.api.user.UserRepository; import app.mealsmadeeasy.api.user.UserRepository;
import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@ -20,6 +21,7 @@ import org.springframework.security.access.AccessDeniedException;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import static app.mealsmadeeasy.api.recipe.ContainsRecipeInfoViewsForRecipesMatcher.containsRecipeInfoViewsForRecipes;
import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes; import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*; import static org.hamcrest.Matchers.*;
@ -76,6 +78,13 @@ public class RecipeServiceTests {
assertThat(recipe.getRawText(), is("Hello!")); assertThat(recipe.getRawText(), is("Hello!"));
} }
@Test
public void createWithoutOwnerThrowsAccessDenied() {
assertThrows(AccessDeniedException.class, () -> this.recipeService.create(
null, RecipeCreateSpec.builder().build()
));
}
@Test @Test
public void getByIdPublicNoViewerDoesNotThrow() { public void getByIdPublicNoViewerDoesNotThrow() {
final User owner = this.seedUser(); final User owner = this.seedUser();
@ -84,7 +93,7 @@ public class RecipeServiceTests {
} }
@Test @Test
public void getByIdHasCorrectProperties() { public void getByIdHasCorrectProperties() throws RecipeException {
final User owner = this.seedUser(); final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, true); final Recipe recipe = this.createTestRecipe(owner, true);
final Recipe byId = this.recipeService.getById(recipe.getId(), null); final Recipe byId = this.recipeService.getById(recipe.getId(), null);
@ -145,7 +154,7 @@ public class RecipeServiceTests {
} }
@Test @Test
public void getByIdWithStarsOkayWhenPublicRecipeWithViewer() { public void getByIdWithStarsOkayWhenPublicRecipeWithViewer() throws RecipeException, ImageException {
final User owner = this.seedUser(); final User owner = this.seedUser();
final User viewer = this.seedUser(); final User viewer = this.seedUser();
final Recipe notYetPublicRecipe = this.createTestRecipe(owner); final Recipe notYetPublicRecipe = this.createTestRecipe(owner);
@ -186,7 +195,7 @@ public class RecipeServiceTests {
} }
@Test @Test
public void getByMinimumStarsOnlySomeViewable() { public void getByMinimumStarsOnlySomeViewable() throws RecipeException {
final User owner = this.seedUser(); final User owner = this.seedUser();
final User u0 = this.seedUser(); final User u0 = this.seedUser();
final User u1 = this.seedUser(); final User u1 = this.seedUser();
@ -236,7 +245,7 @@ public class RecipeServiceTests {
Recipe r0 = this.createTestRecipe(owner, true); Recipe r0 = this.createTestRecipe(owner, true);
Recipe r1 = this.createTestRecipe(owner, true); Recipe r1 = this.createTestRecipe(owner, true);
final List<Recipe> publicRecipes = this.recipeService.getPublicRecipes(Pageable.unpaged()).toList(); final List<Recipe> publicRecipes = this.recipeService.getPublicRecipes();
assertThat(publicRecipes, containsRecipes(r0, r1)); assertThat(publicRecipes, containsRecipes(r0, r1));
} }
@ -246,13 +255,37 @@ public class RecipeServiceTests {
Recipe r0 = this.createTestRecipe(owner, true); Recipe r0 = this.createTestRecipe(owner, true);
Recipe r1 = this.createTestRecipe(owner, false); Recipe r1 = this.createTestRecipe(owner, false);
final Slice<Recipe> viewableInfoViewsSlice = this.recipeService.getViewableBy(Pageable.unpaged(), owner); final Slice<RecipeInfoView> viewableInfoViewsSlice = this.recipeService.getInfoViewsViewableBy(
final List<Recipe> viewableInfos = viewableInfoViewsSlice.toList(); Pageable.ofSize(20),
assertThat(viewableInfos, containsRecipes(r0, r1)); owner
);
final List<RecipeInfoView> viewableInfos = viewableInfoViewsSlice.getContent();
assertThat(viewableInfos, containsRecipeInfoViewsForRecipes(r0, r1));
} }
@Test @Test
public void updateRawText() { 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<Recipe> 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<Recipe> ownedRecipes = this.recipeService.getRecipesOwnedBy(owner);
assertThat(ownedRecipes.size(), is(1));
assertThat(ownedRecipes, containsRecipes(r0));
}
@Test
public void updateRawText() throws RecipeException, ImageException {
final User owner = this.seedUser(); final User owner = this.seedUser();
final RecipeCreateSpec createSpec = RecipeCreateSpec.builder() final RecipeCreateSpec createSpec = RecipeCreateSpec.builder()
.slug(UUID.randomUUID().toString()) .slug(UUID.randomUUID().toString())
@ -297,7 +330,7 @@ public class RecipeServiceTests {
final User owner = this.seedUser(); final User owner = this.seedUser();
final Recipe toDelete = this.createTestRecipe(owner); final Recipe toDelete = this.createTestRecipe(owner);
this.recipeService.deleteRecipe(toDelete.getId(), owner); this.recipeService.deleteRecipe(toDelete.getId(), owner);
assertThrows(NoSuchEntityWithIdException.class, () -> this.recipeService.getById(toDelete.getId(), owner)); assertThrows(RecipeException.class, () -> this.recipeService.getById(toDelete.getId(), owner));
} }
@Test @Test

View File

@ -2,6 +2,7 @@ package app.mealsmadeeasy.api.recipe.star;
import app.mealsmadeeasy.api.IntegrationTestsExtension; import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.recipe.Recipe; import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.recipe.RecipeException;
import app.mealsmadeeasy.api.recipe.RecipeService; import app.mealsmadeeasy.api.recipe.RecipeService;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
@ -62,6 +63,7 @@ public class RecipeStarServiceTests {
recipe.getSlug(), recipe.getSlug(),
starer starer
)); ));
//noinspection DataFlowIssue
assertThat(star.getTimestamp(), is(notNullValue())); assertThat(star.getTimestamp(), is(notNullValue()));
} }
@ -74,11 +76,12 @@ public class RecipeStarServiceTests {
recipe.getId(), recipe.getId(),
starer.getId() starer.getId()
)); ));
//noinspection DataFlowIssue
assertThat(star.getTimestamp(), is(notNullValue())); assertThat(star.getTimestamp(), is(notNullValue()));
} }
@Test @Test
public void find() { public void find() throws RecipeException {
final User owner = this.seedUser(); final User owner = this.seedUser();
final User starer = this.seedUser(); final User starer = this.seedUser();
final Recipe recipe = this.seedRecipe(owner); final Recipe recipe = this.seedRecipe(owner);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

View File

@ -1,41 +0,0 @@
package app.mealsmadeeasy.api.ai;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.Nullable;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class InferenceService {
private final ChatModel chatModel;
public <T> @Nullable T extract(
Message systemMessage,
Message userMessage,
BeanOutputConverter<T> converter
) {
final ChatOptions extractChatOptions = OllamaChatOptions.builder()
.model("gemma3:latest")
.format(converter.getJsonSchemaMap())
.build();
final Prompt extractPrompt = Prompt.builder()
.messages(systemMessage, userMessage)
.chatOptions(extractChatOptions)
.build();
final ChatResponse extractResponse = this.chatModel.call(extractPrompt);
final String extractContent = extractResponse.getResult().getOutput().getText();
if (extractContent == null) {
return null;
}
return converter.convert(extractContent);
}
}

View File

@ -1,33 +0,0 @@
package app.mealsmadeeasy.api.ai;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.Nullable;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class OcrService {
private final ChatModel chatModel;
public @Nullable String readMarkdown(Media inputMedia) {
final Message ocrMessage = UserMessage.builder()
.text("Convert the text in the given file to Markdown.")
.media(inputMedia)
.build();
final Prompt ocrPrompt = Prompt.builder()
.messages(ocrMessage)
.chatOptions(OllamaChatOptions.builder().model("deepseek-ocr:latest").build())
.build();
final ChatResponse ocrResponse = this.chatModel.call(ocrPrompt);
return ocrResponse.getResult().getOutput().getText();
}
}

View File

@ -1,14 +1,12 @@
package app.mealsmadeeasy.api.image; package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.image.body.ImageUpdateBody; 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.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec; import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserService; import app.mealsmadeeasy.api.user.UserService;
import app.mealsmadeeasy.api.util.AccessDeniedView; import app.mealsmadeeasy.api.util.AccessDeniedView;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@ -26,12 +24,15 @@ import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/images") @RequestMapping("/images")
@RequiredArgsConstructor
public class ImageController { public class ImageController {
private final ImageService imageService; private final ImageService imageService;
private final UserService userService; private final UserService userService;
private final ImageToViewConverter imageToViewConverter;
public ImageController(ImageService imageService, UserService userService) {
this.imageService = imageService;
this.userService = userService;
}
private ImageUpdateSpec getImageUpdateSpec(ImageUpdateBody body) { private ImageUpdateSpec getImageUpdateSpec(ImageUpdateBody body) {
final var builder = ImageUpdateSpec.builder() final var builder = ImageUpdateSpec.builder()
@ -72,7 +73,7 @@ public class ImageController {
@AuthenticationPrincipal User principal, @AuthenticationPrincipal User principal,
@PathVariable String username, @PathVariable String username,
@PathVariable String filename @PathVariable String filename
) throws IOException { ) throws ImageException, IOException {
final User owner = this.userService.getUser(username); final User owner = this.userService.getUser(username);
final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal); final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal);
final InputStream imageInputStream = this.imageService.getImageContent(image, principal); final InputStream imageInputStream = this.imageService.getImageContent(image, principal);
@ -109,7 +110,7 @@ public class ImageController {
image.getSize(), image.getSize(),
specBuilder.build() specBuilder.build()
); );
return ResponseEntity.status(201).body(this.imageToViewConverter.convert(saved, principal, true)); return ResponseEntity.status(201).body(this.imageService.toImageView(saved, principal));
} }
@PostMapping("/{username}/{filename}") @PostMapping("/{username}/{filename}")
@ -118,14 +119,14 @@ public class ImageController {
@PathVariable String username, @PathVariable String username,
@PathVariable String filename, @PathVariable String filename,
@RequestBody ImageUpdateBody body @RequestBody ImageUpdateBody body
) { ) throws ImageException {
if (principal == null) { if (principal == null) {
throw new AccessDeniedException("Must be logged in."); throw new AccessDeniedException("Must be logged in.");
} }
final User owner = this.userService.getUser(username); final User owner = this.userService.getUser(username);
final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal); final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal);
final Image updated = this.imageService.update(image, principal, this.getImageUpdateSpec(body)); final Image updated = this.imageService.update(image, principal, this.getImageUpdateSpec(body));
return ResponseEntity.ok(this.imageToViewConverter.convert(updated, principal, true)); return ResponseEntity.ok(this.imageService.toImageView(updated, principal));
} }
@DeleteMapping("/{username}/{filename}") @DeleteMapping("/{username}/{filename}")
@ -133,7 +134,7 @@ public class ImageController {
@AuthenticationPrincipal User principal, @AuthenticationPrincipal User principal,
@PathVariable String username, @PathVariable String username,
@PathVariable String filename @PathVariable String filename
) throws IOException { ) throws ImageException, IOException {
if (principal == null) { if (principal == null) {
throw new AccessDeniedException("Must be logged in."); throw new AccessDeniedException("Must be logged in.");
} }

View File

@ -3,6 +3,9 @@ package app.mealsmadeeasy.api.image;
public class ImageException extends Exception { public class ImageException extends Exception {
public enum Type { public enum Type {
INVALID_ID,
INVALID_USERNAME_OR_FILENAME,
IMAGE_NOT_FOUND,
UNSUPPORTED_IMAGE_TYPE, UNSUPPORTED_IMAGE_TYPE,
} }

View File

@ -1,7 +1,6 @@
package app.mealsmadeeasy.api.image; package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -17,7 +16,7 @@ public class ImageSecurityImpl implements ImageSecurity {
} }
@Override @Override
public boolean isViewableBy(@NotNull Image image, @Nullable User viewer) { public boolean isViewableBy(Image image, @Nullable User viewer) {
if (image.getIsPublic()) { if (image.getIsPublic()) {
// public image // public image
return true; return true;
@ -41,7 +40,7 @@ public class ImageSecurityImpl implements ImageSecurity {
} }
@Override @Override
public boolean isOwner(@NotNull Image image, @Nullable User user) { public boolean isOwner(Image image, @Nullable User user) {
return image.getOwner() != null && user != null && Objects.equals(image.getOwner().getId(), user.getId()); return image.getOwner() != null && user != null && Objects.equals(image.getOwner().getId(), user.getId());
} }

View File

@ -2,6 +2,7 @@ package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec; import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec; 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.User;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
@ -14,9 +15,9 @@ public interface ImageService {
Image create(User owner, String userFilename, InputStream inputStream, long objectSize, ImageCreateSpec infoSpec) Image create(User owner, String userFilename, InputStream inputStream, long objectSize, ImageCreateSpec infoSpec)
throws IOException, ImageException; throws IOException, ImageException;
Image getById(Integer id, @Nullable User viewer); Image getById(Integer id, @Nullable User viewer) throws ImageException;
Image getByOwnerAndFilename(User owner, String filename, User viewer); Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException;
Image getByUsernameAndFilename(String username, String filename, User viewer); Image getByUsernameAndFilename(String username, String filename, User viewer) throws ImageException;
InputStream getImageContent(Image image, @Nullable User viewer) throws IOException; InputStream getImageContent(Image image, @Nullable User viewer) throws IOException;
List<Image> getImagesOwnedBy(User user); List<Image> getImagesOwnedBy(User user);
@ -25,4 +26,6 @@ public interface ImageService {
void deleteImage(Image image, User modifier) throws IOException; void deleteImage(Image image, User modifier) throws IOException;
ImageView toImageView(Image image, @Nullable User viewer);
} }

View File

@ -2,11 +2,10 @@ package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec; import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec; import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.s3.S3Manager; import app.mealsmadeeasy.api.s3.S3Manager;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.util.MimeTypeService; 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.jetbrains.annotations.Nullable;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PostAuthorize;
@ -28,17 +27,20 @@ public class S3ImageService implements ImageService {
private final S3Manager s3Manager; private final S3Manager s3Manager;
private final ImageRepository imageRepository; private final ImageRepository imageRepository;
private final String imageBucketName; private final String imageBucketName;
private final String baseUrl;
private final MimeTypeService mimeTypeService; private final MimeTypeService mimeTypeService;
public S3ImageService( public S3ImageService(
S3Manager s3Manager, S3Manager s3Manager,
ImageRepository imageRepository, ImageRepository imageRepository,
@Value("${app.mealsmadeeasy.api.images.bucketName}") String imageBucketName, @Value("${app.mealsmadeeasy.api.images.bucketName}") String imageBucketName,
@Value("${app.mealsmadeeasy.api.baseUrl}") String baseUrl,
MimeTypeService mimeTypeService MimeTypeService mimeTypeService
) { ) {
this.s3Manager = s3Manager; this.s3Manager = s3Manager;
this.imageRepository = imageRepository; this.imageRepository = imageRepository;
this.imageBucketName = imageBucketName; this.imageBucketName = imageBucketName;
this.baseUrl = baseUrl;
this.mimeTypeService = mimeTypeService; this.mimeTypeService = mimeTypeService;
} }
@ -162,38 +164,42 @@ public class S3ImageService implements ImageService {
@Override @Override
@PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)") @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)")
public Image getById(Integer id, @Nullable User viewer) { public Image getById(Integer id, @Nullable User viewer) throws ImageException {
return this.imageRepository.findById(id).orElseThrow(() -> new NoSuchEntityWithIdException(Image.class, id)); return this.imageRepository.findById(id).orElseThrow(() -> new ImageException(
ImageException.Type.INVALID_ID, "No Image with id: " + id
));
} }
@Override @Override
@PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)") @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)")
public Image getByOwnerAndFilename(User owner, String filename, User viewer) { public Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException {
return this.imageRepository.findByOwnerAndUserFilename(owner, filename) return this.imageRepository.findByOwnerAndUserFilename((User) owner, filename)
.orElseThrow(() -> new NoSuchEntityWithUsernameAndFilenameException( .orElseThrow(() -> new ImageException(
Image.class, ImageException.Type.IMAGE_NOT_FOUND,
owner.getUsername(), "No such image for owner " + owner + " with filename " + filename
filename
)); ));
} }
@Override @Override
@PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)") @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)")
public Image getByUsernameAndFilename(String username, String filename, User viewer) { public Image getByUsernameAndFilename(String username, String filename, User viewer) throws ImageException {
return this.imageRepository.findByOwnerUsernameAndFilename(username, filename).orElseThrow( return this.imageRepository.findByOwnerUsernameAndFilename(username, filename).orElseThrow(
() -> new NoSuchEntityWithUsernameAndFilenameException(Image.class, username, filename) () -> new ImageException(
ImageException.Type.INVALID_USERNAME_OR_FILENAME,
"No such Image for username " + username + " and filename " + filename
)
); );
} }
@Override @Override
@PreAuthorize("@imageSecurity.isViewableBy(#image, #viewer)") @PreAuthorize("@imageSecurity.isViewableBy(#image, #viewer)")
public InputStream getImageContent(Image image, User viewer) throws IOException { public InputStream getImageContent(Image image, User viewer) throws IOException {
return this.s3Manager.load(this.imageBucketName, image.getObjectName()); return this.s3Manager.load(this.imageBucketName, ((Image) image).getObjectName());
} }
@Override @Override
public List<Image> getImagesOwnedBy(User user) { public List<Image> getImagesOwnedBy(User user) {
return new ArrayList<>(this.imageRepository.findAllByOwner(user)); return new ArrayList<>(this.imageRepository.findAllByOwner((User) user));
} }
@Override @Override
@ -213,4 +219,17 @@ public class S3ImageService implements ImageService {
this.s3Manager.delete("images", image.getObjectName()); 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);
}
}
} }

View File

@ -1,54 +0,0 @@
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();
}
}

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.image.view; package app.mealsmadeeasy.api.image.view;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.user.view.UserInfoView; import app.mealsmadeeasy.api.user.view.UserInfoView;
import lombok.Builder; import lombok.Builder;
import lombok.Value; import lombok.Value;
@ -7,10 +8,35 @@ import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
@Value @Value
@Builder @Builder
public class ImageView { 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; String url;
OffsetDateTime created; OffsetDateTime created;
@Nullable OffsetDateTime modified; @Nullable OffsetDateTime modified;
@ -23,4 +49,5 @@ public class ImageView {
@Nullable Integer height; @Nullable Integer height;
@Nullable Integer width; @Nullable Integer width;
@Nullable Set<UserInfoView> viewers; @Nullable Set<UserInfoView> viewers;
} }

View File

@ -0,0 +1,37 @@
package app.mealsmadeeasy.api.inference;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.util.Map;
@RestController
@RequestMapping("/inferences")
public class InferenceController {
private final InferenceService inferenceService;
public InferenceController(InferenceService inferenceService) {
this.inferenceService = inferenceService;
}
@PutMapping("/recipe-extract")
public ResponseEntity<String> recipeExtract(@RequestParam MultipartFile recipeImageFile) throws IOException {
return ResponseEntity.ok(this.inferenceService.extractRecipe(recipeImageFile.getInputStream()));
}
@PutMapping(value = "/recipe-extract-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<Map<String, String>>> recipeExtractStream(@RequestParam MultipartFile recipeImageFile) throws IOException {
return this.inferenceService.extractRecipeStream(recipeImageFile.getInputStream())
.map(data -> ServerSentEvent.builder(Map.of("delta", data)).build());
}
}

View File

@ -0,0 +1,52 @@
package app.mealsmadeeasy.api.inference;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.content.Media;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.InputStreamResource;
import org.springframework.stereotype.Service;
import org.springframework.util.MimeType;
import reactor.core.publisher.Flux;
import java.io.InputStream;
@Service
public class InferenceService {
private final ChatClient chatClient;
public InferenceService(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
public String extractRecipe(InputStream recipeImageInputStream) {
final Media media = Media.builder()
.data(new InputStreamResource(recipeImageInputStream))
.mimeType(MimeType.valueOf("image/jpeg"))
.build();
final String markdownResponse = this.chatClient.prompt()
.user(u ->
u.text(new ClassPathResource("app/mealsmadeeasy/api/inference/recipe-extract-user-prompt.md"))
.media(media)
)
.call()
.content();
return markdownResponse;
}
public Flux<String> extractRecipeStream(InputStream recipeImageInputStream) {
final Media media = Media.builder()
.data(new InputStreamResource(recipeImageInputStream))
.mimeType(MimeType.valueOf("image/jpeg"))
.build();
return this.chatClient.prompt()
.user(u ->
u.text(new ClassPathResource("app/mealsmadeeasy/api/inference/recipe-extract-user-prompt.md"))
.media(media)
)
.stream().content();
}
}

View File

@ -0,0 +1,36 @@
package app.mealsmadeeasy.api.inference;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "recipe_extraction")
public class RecipeExtractionEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Lob
@Column(columnDefinition = "TEXT")
@Basic(fetch = FetchType.LAZY)
private String markdown;
public UUID getId() {
return this.id;
}
public void setId(UUID id) {
this.id = id;
}
public String getMarkdown() {
return this.markdown;
}
public void setMarkdown(String markdown) {
this.markdown = markdown;
}
}

View File

@ -0,0 +1,33 @@
package app.mealsmadeeasy.api.inference;
import java.util.UUID;
public class RecipeExtractionView {
public static RecipeExtractionView from(RecipeExtractionEntity entity) {
final var view = new RecipeExtractionView();
view.setId(entity.getId());
view.setMarkdown(entity.getMarkdown());
return view;
}
private UUID id;
private String markdown;
public UUID getId() {
return this.id;
}
public void setId(UUID id) {
this.id = id;
}
public String getMarkdown() {
return this.markdown;
}
public void setMarkdown(String markdown) {
this.markdown = markdown;
}
}

View File

@ -71,7 +71,7 @@ public class Recipe {
joinColumns = @JoinColumn(name = "recipe_id"), joinColumns = @JoinColumn(name = "recipe_id"),
inverseJoinColumns = @JoinColumn(name = "viewer_id") inverseJoinColumns = @JoinColumn(name = "viewer_id")
) )
private Set<User> viewers = new HashSet<>(); // todo: see if we can get rid of this init private Set<User> viewers = new HashSet<>();
@ManyToOne @ManyToOne
@JoinColumn(name = "main_image_id") @JoinColumn(name = "main_image_id")
@ -81,14 +81,4 @@ public class Recipe {
@OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private RecipeEmbedding embedding; private RecipeEmbedding embedding;
@PrePersist
private void prePersist() {
this.created = OffsetDateTime.now();
}
@PreUpdate
private void preUpdate() {
this.modified = OffsetDateTime.now();
}
} }

View File

@ -71,14 +71,4 @@ public class RecipeDraft {
@Column(columnDefinition = "jsonb") @Column(columnDefinition = "jsonb")
private @Nullable List<RecipeDraftInference> inferences; private @Nullable List<RecipeDraftInference> inferences;
@PrePersist
private void prePersist() {
this.created = OffsetDateTime.now();
}
@PreUpdate
private void preUpdate() {
this.modified = OffsetDateTime.now();
}
} }

View File

@ -2,16 +2,18 @@ package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.file.File; import app.mealsmadeeasy.api.file.File;
import app.mealsmadeeasy.api.file.FileService; 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.body.RecipeDraftUpdateBody;
import app.mealsmadeeasy.api.recipe.converter.RecipeDraftToViewConverter;
import app.mealsmadeeasy.api.recipe.converter.RecipeDraftUpdateBodyToSpecConverter; 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.spec.RecipeDraftUpdateSpec;
import app.mealsmadeeasy.api.recipe.view.FullRecipeView; import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
import app.mealsmadeeasy.api.recipe.view.RecipeDraftView; import app.mealsmadeeasy.api.recipe.view.RecipeDraftView;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.util.MustBeLoggedInException; import app.mealsmadeeasy.api.util.MustBeLoggedInException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.Nullable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@ -29,9 +31,14 @@ public class RecipeDraftsController {
private final RecipeService recipeService; private final RecipeService recipeService;
private final FileService fileService; private final FileService fileService;
private final ImageService imageService;
private final RecipeDraftUpdateBodyToSpecConverter updateBodyToSpecConverter; private final RecipeDraftUpdateBodyToSpecConverter updateBodyToSpecConverter;
private final RecipeDraftToViewConverter draftToViewConverter;
private final RecipeToFullViewConverter recipeToFullViewConverter; private @Nullable ImageView getImageView(RecipeDraft recipeDraft, User viewer) {
return recipeDraft.getMainImage() != null
? this.imageService.toImageView(recipeDraft.getMainImage(), viewer)
: null;
}
@GetMapping @GetMapping
public ResponseEntity<List<RecipeDraftView>> getAllDraftsForUser(@AuthenticationPrincipal User user) { public ResponseEntity<List<RecipeDraftView>> getAllDraftsForUser(@AuthenticationPrincipal User user) {
@ -39,10 +46,10 @@ public class RecipeDraftsController {
throw new MustBeLoggedInException(); throw new MustBeLoggedInException();
} }
final List<RecipeDraft> recipeDrafts = this.recipeService.getDrafts(user); final List<RecipeDraft> recipeDrafts = this.recipeService.getDrafts(user);
return ResponseEntity.ok(recipeDrafts.stream() return ResponseEntity.ok(recipeDrafts.stream().map(recipeDraft -> {
.map(recipeDraft -> this.draftToViewConverter.convert(recipeDraft, user)) final @Nullable ImageView mainImage = this.getImageView(recipeDraft, user);
.toList() return RecipeDraftView.from(recipeDraft, mainImage);
); }).toList());
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@ -54,7 +61,8 @@ public class RecipeDraftsController {
throw new MustBeLoggedInException(); throw new MustBeLoggedInException();
} }
final RecipeDraft recipeDraft = this.recipeService.getDraftByIdWithViewer(id, viewer); final RecipeDraft recipeDraft = this.recipeService.getDraftByIdWithViewer(id, viewer);
return ResponseEntity.ok(this.draftToViewConverter.convert(recipeDraft, viewer)); final @Nullable ImageView imageView = this.getImageView(recipeDraft, viewer);
return ResponseEntity.ok(RecipeDraftView.from(recipeDraft, imageView));
} }
@PostMapping("/manual") @PostMapping("/manual")
@ -63,7 +71,8 @@ public class RecipeDraftsController {
throw new MustBeLoggedInException(); throw new MustBeLoggedInException();
} }
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner); final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
return ResponseEntity.status(HttpStatus.CREATED).body(this.draftToViewConverter.convert(recipeDraft, owner)); final ImageView imageView = this.getImageView(recipeDraft, owner);
return ResponseEntity.ok(RecipeDraftView.from(recipeDraft, imageView));
} }
@PostMapping("/ai") @PostMapping("/ai")
@ -82,7 +91,8 @@ public class RecipeDraftsController {
owner owner
); );
final RecipeDraft recipeDraft = this.recipeService.createAiDraft(file, owner); final RecipeDraft recipeDraft = this.recipeService.createAiDraft(file, owner);
return ResponseEntity.status(HttpStatus.CREATED).body(this.draftToViewConverter.convert(recipeDraft, owner)); final @Nullable ImageView mainImageView = this.getImageView(recipeDraft, owner);
return ResponseEntity.status(HttpStatus.CREATED).body(RecipeDraftView.from(recipeDraft, mainImageView));
} }
@PutMapping("/{id}") @PutMapping("/{id}")
@ -90,13 +100,14 @@ public class RecipeDraftsController {
@AuthenticationPrincipal User modifier, @AuthenticationPrincipal User modifier,
@PathVariable UUID id, @PathVariable UUID id,
@RequestBody RecipeDraftUpdateBody updateBody @RequestBody RecipeDraftUpdateBody updateBody
) { ) throws ImageException {
if (modifier == null) { if (modifier == null) {
throw new MustBeLoggedInException(); throw new MustBeLoggedInException();
} }
final RecipeDraftUpdateSpec spec = this.updateBodyToSpecConverter.convert(updateBody, modifier); final RecipeDraftUpdateSpec spec = this.updateBodyToSpecConverter.convert(updateBody, modifier);
final RecipeDraft updated = this.recipeService.updateDraft(id, spec, modifier); final RecipeDraft updated = this.recipeService.updateDraft(id, spec, modifier);
return ResponseEntity.ok(this.draftToViewConverter.convert(updated, modifier)); final @Nullable ImageView imageView = this.getImageView(updated, modifier);
return ResponseEntity.ok(RecipeDraftView.from(updated, imageView));
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@ -120,7 +131,7 @@ public class RecipeDraftsController {
throw new MustBeLoggedInException(); throw new MustBeLoggedInException();
} }
final Recipe recipe = this.recipeService.publishDraft(id, modifier); final Recipe recipe = this.recipeService.publishDraft(id, modifier);
final FullRecipeView view = this.recipeToFullViewConverter.convert(recipe, false, modifier); final FullRecipeView view = this.recipeService.toFullRecipeView(recipe, false, modifier);
return ResponseEntity.status(HttpStatus.CREATED).body(view); return ResponseEntity.status(HttpStatus.CREATED).body(view);
} }

View File

@ -0,0 +1,27 @@
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;
}
}

View File

@ -12,7 +12,7 @@ import java.util.Optional;
public interface RecipeRepository extends JpaRepository<Recipe, Integer> { public interface RecipeRepository extends JpaRepository<Recipe, Integer> {
Slice<Recipe> findAllByIsPublicIsTrue(Pageable pageable); List<Recipe> findAllByIsPublicIsTrue();
List<Recipe> findAllByViewersContaining(User viewer); List<Recipe> findAllByViewersContaining(User viewer);

View File

@ -1,8 +1,6 @@
package app.mealsmadeeasy.api.recipe; package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.user.User; 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.jetbrains.annotations.Nullable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -21,21 +19,25 @@ public class RecipeSecurity {
return recipe.getOwner() != null && recipe.getOwner().getId().equals(user.getId()); return recipe.getOwner() != null && recipe.getOwner().getId().equals(user.getId());
} }
public boolean isOwner(Integer recipeId, User user) { public boolean isOwner(Integer recipeId, User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow( final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException(
() -> new NoSuchEntityWithIdException(Recipe.class, recipeId) RecipeException.Type.INVALID_ID,
); "No such Recipe with id " + recipeId
));
return this.isOwner(recipe, user); return this.isOwner(recipe, user);
} }
public boolean isOwner(String username, String slug, @Nullable User user) { public boolean isOwner(String username, String slug, @Nullable User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow( final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(
() -> new NoSuchEntityWithUsernameAndSlugException(Recipe.class, username, slug) () -> new RecipeException(
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
"No such Recipe for username " + username + " and slug " + slug
)
); );
return this.isOwner(recipe, user); return this.isOwner(recipe, user);
} }
public boolean isViewableBy(Recipe recipe, @Nullable User user) { public boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException {
if (recipe.getIsPublic()) { if (recipe.getIsPublic()) {
// public recipe // public recipe
return true; return true;
@ -48,7 +50,9 @@ public class RecipeSecurity {
} else { } else {
// check if viewer // check if viewer
final Recipe withViewers = this.recipeRepository.findByIdWithViewers(recipe.getId()) final Recipe withViewers = this.recipeRepository.findByIdWithViewers(recipe.getId())
.orElseThrow(() -> new NoSuchEntityWithIdException(Recipe.class, recipe.getId())); .orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID, "No such Recipe with id: " + recipe.getId()
));
for (final User viewer : withViewers.getViewers()) { for (final User viewer : withViewers.getViewers()) {
if (viewer.getId() != null && viewer.getId().equals(user.getId())) { if (viewer.getId() != null && viewer.getId().equals(user.getId())) {
return true; return true;
@ -59,16 +63,20 @@ public class RecipeSecurity {
return false; return false;
} }
public boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) { public boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(ownerUsername, slug) final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(ownerUsername, slug)
.orElseThrow(() -> new NoSuchEntityWithUsernameAndSlugException(Recipe.class, 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); return this.isViewableBy(recipe, user);
} }
public boolean isViewableBy(Integer recipeId, @Nullable User user) { public boolean isViewableBy(Integer recipeId, @Nullable User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow( final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException(
() -> new NoSuchEntityWithIdException(Recipe.class, recipeId) RecipeException.Type.INVALID_ID,
); "No such Recipe with id: " + recipeId
));
return this.isViewableBy(recipe, user); return this.isViewableBy(recipe, user);
} }

View File

@ -2,7 +2,9 @@ package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.file.File; import app.mealsmadeeasy.api.file.File;
import app.mealsmadeeasy.api.image.Image; import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.image.ImageService; import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.job.JobService; import app.mealsmadeeasy.api.job.JobService;
import app.mealsmadeeasy.api.markdown.MarkdownService; import app.mealsmadeeasy.api.markdown.MarkdownService;
import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody; import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody;
@ -12,16 +14,18 @@ import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository; 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.user.User;
import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException; import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException;
import app.mealsmadeeasy.api.util.NoSuchEntityWithUsernameAndSlugException;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice; 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.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -33,7 +37,6 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
@Service @Service
@RequiredArgsConstructor
public class RecipeService { public class RecipeService {
private final RecipeRepository recipeRepository; private final RecipeRepository recipeRepository;
@ -44,8 +47,30 @@ public class RecipeService {
private final RecipeDraftRepository recipeDraftRepository; private final RecipeDraftRepository recipeDraftRepository;
private final JobService jobService; private final JobService jobService;
public Recipe create(User owner, RecipeCreateSpec spec) { 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.");
}
final Recipe draft = new Recipe(); final Recipe draft = new Recipe();
draft.setCreated(OffsetDateTime.now());
draft.setOwner(owner); draft.setOwner(owner);
draft.setSlug(spec.getSlug()); draft.setSlug(spec.getSlug());
draft.setTitle(spec.getTitle()); draft.setTitle(spec.getTitle());
@ -55,33 +80,34 @@ public class RecipeService {
return this.recipeRepository.save(draft); return this.recipeRepository.save(draft);
} }
private Recipe getById(Integer id) { private Recipe findRecipeEntity(Integer id) throws RecipeException {
return this.recipeRepository.findById(id).orElseThrow(() -> new NoSuchEntityWithIdException(Recipe.class, id)); return this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException(
} RecipeException.Type.INVALID_ID, "No such Recipe with id: " + 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)") @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
public Recipe getById(Integer id, @Nullable User viewer) { public Recipe getById(Integer id, @Nullable User viewer) throws RecipeException {
return this.getById(id); return this.findRecipeEntity(id);
} }
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)") @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
public Recipe getByIdWithStars(Integer id, @Nullable User viewer) { public Recipe getByIdWithStars(Integer id, @Nullable User viewer) throws RecipeException {
return this.recipeRepository.findByIdWithStars(id).orElseThrow( return this.recipeRepository.findByIdWithStars(id).orElseThrow(() -> new RecipeException(
() -> new NoSuchEntityWithIdException(Recipe.class, id) RecipeException.Type.INVALID_ID,
); "No such Recipe with id: " + id
));
} }
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)") @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
public Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) { public Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) throws RecipeException {
return this.getByUsernameAndSlug(username, slug); return this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
"No such Recipe for username " + username + " and slug " + slug
));
} }
@ApiStatus.Internal
public String getRenderedMarkdown(Recipe entity) { public String getRenderedMarkdown(Recipe entity) {
if (entity.getCachedRenderedText() == null) { if (entity.getCachedRenderedText() == null) {
entity.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(entity.getRawText())); entity.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(entity.getRawText()));
@ -90,16 +116,69 @@ public class RecipeService {
return entity.getCachedRenderedText(); return entity.getCachedRenderedText();
} }
public int getStarCount(Recipe recipe) { private int getStarCount(Recipe recipe) {
return this.recipeRepository.getStarCount(recipe.getId()); return this.recipeRepository.getStarCount(recipe.getId());
} }
public int getViewerCount(Recipe recipe) { private int getViewerCount(long recipeId) {
return this.recipeRepository.getViewerCount(recipe.getId()); return this.recipeRepository.getViewerCount(recipeId);
} }
public Slice<Recipe> getViewableBy(Pageable pageable, User viewer) { @Contract("null, _ -> null")
return this.recipeRepository.findAllViewableBy(viewer, pageable); 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<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer) {
return this.recipeRepository.findAllViewableBy(viewer, pageable).map(recipe ->
this.getInfoView(recipe, viewer)
);
} }
public List<Recipe> getByMinimumStars(long minimumStars, @Nullable User viewer) { public List<Recipe> getByMinimumStars(long minimumStars, @Nullable User viewer) {
@ -108,11 +187,19 @@ public class RecipeService {
); );
} }
public Slice<Recipe> getPublicRecipes(Pageable pageable) { public List<Recipe> getPublicRecipes() {
return this.recipeRepository.findAllByIsPublicIsTrue(pageable); return List.copyOf(this.recipeRepository.findAllByIsPublicIsTrue());
} }
public List<Recipe> aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) { public List<Recipe> getRecipesViewableBy(User viewer) {
return List.copyOf(this.recipeRepository.findAllByViewersContaining(viewer));
}
public List<Recipe> getRecipesOwnedBy(User owner) {
return List.copyOf(this.recipeRepository.findAllByOwner(owner));
}
public List<RecipeInfoView> aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) {
final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt()); final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt());
final List<Recipe> results; final List<Recipe> results;
if (viewer == null) { if (viewer == null) {
@ -120,30 +207,39 @@ public class RecipeService {
} else { } else {
results = this.recipeRepository.searchByEmbeddingAndViewableBy(queryEmbedding, 0.5f, viewer.getId()); results = this.recipeRepository.searchByEmbeddingAndViewableBy(queryEmbedding, 0.5f, viewer.getId());
} }
return results; return results.stream()
.map(recipeEntity -> this.getInfoView(recipeEntity, viewer))
.toList();
} }
private void prepareForUpdate(RecipeUpdateSpec spec, Recipe recipe, User modifier) { private void prepareForUpdate(RecipeUpdateSpec spec, Recipe recipe, User modifier) throws ImageException {
boolean didUpdate = false;
if (spec.getTitle() != null) { if (spec.getTitle() != null) {
recipe.setTitle(spec.getTitle()); recipe.setTitle(spec.getTitle());
didUpdate = true;
} }
if (spec.getPreparationTime() != null) { if (spec.getPreparationTime() != null) {
recipe.setPreparationTime(spec.getPreparationTime()); recipe.setPreparationTime(spec.getPreparationTime());
didUpdate = true;
} }
if (spec.getCookingTime() != null) { if (spec.getCookingTime() != null) {
recipe.setCookingTime(spec.getCookingTime()); recipe.setCookingTime(spec.getCookingTime());
didUpdate = true;
} }
if (spec.getTotalTime() != null) { if (spec.getTotalTime() != null) {
recipe.setTotalTime(spec.getTotalTime()); recipe.setTotalTime(spec.getTotalTime());
didUpdate = true;
} }
if (spec.getRawText() != null) { if (spec.getRawText() != null) {
recipe.setRawText(spec.getRawText()); recipe.setRawText(spec.getRawText());
recipe.setCachedRenderedText(null); recipe.setCachedRenderedText(null);
didUpdate = true;
} }
if (spec.getIsPublic() != null) { if (spec.getIsPublic() != null) {
recipe.setIsPublic(spec.getIsPublic()); recipe.setIsPublic(spec.getIsPublic());
didUpdate = true;
} }
// TODO: we have to think about how to unset the main image vs. just leaving it out of the request // TODO: we have to think about how to unset the main image vs. just leaving it out of the request
@ -155,20 +251,30 @@ public class RecipeService {
); );
recipe.setMainImage(mainImage); recipe.setMainImage(mainImage);
} }
if (didUpdate) {
recipe.setModified(OffsetDateTime.now());
}
} }
@PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)") @PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)")
public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier) { public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
final Recipe recipe = this.getByUsernameAndSlug(username, slug); 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
)
);
this.prepareForUpdate(spec, recipe, modifier); this.prepareForUpdate(spec, recipe, modifier);
return this.recipeRepository.save(recipe); return this.recipeRepository.save(recipe);
} }
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public Recipe addViewer(Integer id, User modifier, User viewer) { public Recipe addViewer(Integer id, User modifier, User viewer) throws RecipeException {
final Recipe entity = this.recipeRepository.findByIdWithViewers(id).orElseThrow( final Recipe entity = this.recipeRepository.findByIdWithViewers(id).orElseThrow(() -> new RecipeException(
() -> new NoSuchEntityWithIdException(Recipe.class, id) RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id
); ));
final Set<User> viewers = new HashSet<>(entity.getViewers()); final Set<User> viewers = new HashSet<>(entity.getViewers());
viewers.add(viewer); viewers.add(viewer);
entity.setViewers(viewers); entity.setViewers(viewers);
@ -176,8 +282,8 @@ public class RecipeService {
} }
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public Recipe removeViewer(Integer id, User modifier, User viewer) { public Recipe removeViewer(Integer id, User modifier, User viewer) throws RecipeException {
final Recipe entity = this.getById(id); final Recipe entity = this.findRecipeEntity(id);
final Set<User> viewers = new HashSet<>(entity.getViewers()); final Set<User> viewers = new HashSet<>(entity.getViewers());
viewers.remove(viewer); viewers.remove(viewer);
entity.setViewers(viewers); entity.setViewers(viewers);
@ -185,8 +291,8 @@ public class RecipeService {
} }
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public Recipe clearAllViewers(Integer id, User modifier) { public Recipe clearAllViewers(Integer id, User modifier) throws RecipeException {
final Recipe entity = this.getById(id); final Recipe entity = this.findRecipeEntity(id);
entity.setViewers(new HashSet<>()); entity.setViewers(new HashSet<>());
return this.recipeRepository.save(entity); return this.recipeRepository.save(entity);
} }
@ -196,6 +302,14 @@ public class RecipeService {
this.recipeRepository.deleteById(id); 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)") @PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
@Contract("_, _, null -> null") @Contract("_, _, null -> null")
public @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer) { public @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer) {
@ -221,6 +335,7 @@ public class RecipeService {
public RecipeDraft createDraft(User owner) { public RecipeDraft createDraft(User owner) {
final var recipeDraft = new RecipeDraft(); final var recipeDraft = new RecipeDraft();
recipeDraft.setState(RecipeDraft.State.ENTER_DATA); recipeDraft.setState(RecipeDraft.State.ENTER_DATA);
recipeDraft.setCreated(OffsetDateTime.now());
recipeDraft.setOwner(owner); recipeDraft.setOwner(owner);
return this.recipeDraftRepository.save(recipeDraft); return this.recipeDraftRepository.save(recipeDraft);
} }
@ -229,6 +344,7 @@ public class RecipeService {
public RecipeDraft createAiDraft(File sourceFile, User owner) { public RecipeDraft createAiDraft(File sourceFile, User owner) {
final var recipeDraft = new RecipeDraft(); final var recipeDraft = new RecipeDraft();
recipeDraft.setState(RecipeDraft.State.INFER); recipeDraft.setState(RecipeDraft.State.INFER);
recipeDraft.setCreated(OffsetDateTime.now());
recipeDraft.setOwner(owner); recipeDraft.setOwner(owner);
final var saved = this.recipeDraftRepository.save(recipeDraft); final var saved = this.recipeDraftRepository.save(recipeDraft);
@ -297,6 +413,7 @@ public class RecipeService {
if (spec.mainImage() != null) { if (spec.mainImage() != null) {
recipeDraft.setMainImage(spec.mainImage()); recipeDraft.setMainImage(spec.mainImage());
} }
recipeDraft.setModified(OffsetDateTime.now());
return this.recipeDraftRepository.save(recipeDraft); return this.recipeDraftRepository.save(recipeDraft);
} }

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.recipe; package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody; import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody;
import app.mealsmadeeasy.api.recipe.body.RecipeSearchBody; import app.mealsmadeeasy.api.recipe.body.RecipeSearchBody;
import app.mealsmadeeasy.api.recipe.body.RecipeUpdateBody; import app.mealsmadeeasy.api.recipe.body.RecipeUpdateBody;
@ -7,17 +8,15 @@ import app.mealsmadeeasy.api.recipe.comment.RecipeComment;
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentCreateBody; import app.mealsmadeeasy.api.recipe.comment.RecipeCommentCreateBody;
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentService; import app.mealsmadeeasy.api.recipe.comment.RecipeCommentService;
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentView; 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.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStar; import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.recipe.star.RecipeStarService; import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
import app.mealsmadeeasy.api.recipe.view.FullRecipeView; 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.recipe.view.RecipeInfoView;
import app.mealsmadeeasy.api.sliceview.SliceViewService; import app.mealsmadeeasy.api.sliceview.SliceViewService;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice; import org.springframework.data.domain.Slice;
@ -33,7 +32,6 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/recipes") @RequestMapping("/recipes")
@RequiredArgsConstructor
public class RecipesController { public class RecipesController {
private final RecipeService recipeService; private final RecipeService recipeService;
@ -41,8 +39,32 @@ public class RecipesController {
private final RecipeCommentService recipeCommentService; private final RecipeCommentService recipeCommentService;
private final SliceViewService sliceViewService; private final SliceViewService sliceViewService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final RecipeToFullViewConverter recipeToFullViewConverter;
private final RecipeToInfoViewConverter recipeToInfoViewConverter; 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<RecipeExceptionView> 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 Map<String, Object> getFullViewWrapper(String username, String slug, FullRecipeView view, @Nullable User viewer) { private Map<String, Object> getFullViewWrapper(String username, String slug, FullRecipeView view, @Nullable User viewer) {
Map<String, Object> wrapper = new HashMap<>(); Map<String, Object> wrapper = new HashMap<>();
@ -58,9 +80,13 @@ public class RecipesController {
@PathVariable String slug, @PathVariable String slug,
@RequestParam(defaultValue = "false") boolean includeRawText, @RequestParam(defaultValue = "false") boolean includeRawText,
@AuthenticationPrincipal User viewer @AuthenticationPrincipal User viewer
) { ) throws RecipeException {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(username, slug, viewer); final FullRecipeView view = this.recipeService.getFullViewByUsernameAndSlug(
final FullRecipeView view = this.recipeToFullViewConverter.convert(recipe, includeRawText, viewer); username,
slug,
includeRawText,
viewer
);
return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, viewer)); return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, viewer));
} }
@ -71,10 +97,10 @@ public class RecipesController {
@RequestParam(defaultValue = "true") boolean includeRawText, @RequestParam(defaultValue = "true") boolean includeRawText,
@RequestBody RecipeUpdateBody updateBody, @RequestBody RecipeUpdateBody updateBody,
@AuthenticationPrincipal User principal @AuthenticationPrincipal User principal
) { ) throws ImageException, RecipeException {
final RecipeUpdateSpec spec = RecipeUpdateSpec.from(updateBody); final RecipeUpdateSpec spec = RecipeUpdateSpec.from(updateBody);
final Recipe updated = this.recipeService.update(username, slug, spec, principal); final Recipe updated = this.recipeService.update(username, slug, spec, principal);
final FullRecipeView view = this.recipeToFullViewConverter.convert(updated, includeRawText, principal); final FullRecipeView view = this.recipeService.toFullRecipeView(updated, includeRawText, principal);
return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, principal)); return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, principal));
} }
@ -83,19 +109,8 @@ public class RecipesController {
Pageable pageable, Pageable pageable,
@AuthenticationPrincipal User user @AuthenticationPrincipal User user
) { ) {
if (user == null) { final Slice<RecipeInfoView> slice = this.recipeService.getInfoViewsViewableBy(pageable, user);
final Slice<Recipe> publicRecipes = this.recipeService.getPublicRecipes(pageable); return ResponseEntity.ok(this.sliceViewService.getSliceView(slice));
final Slice<RecipeInfoView> publicRecipeInfoViews = publicRecipes.map(
recipe -> this.recipeToInfoViewConverter.convert(recipe, null)
);
return ResponseEntity.ok(this.sliceViewService.getSliceView(publicRecipeInfoViews));
} else {
final Slice<Recipe> recipes = this.recipeService.getViewableBy(pageable, user);
final Slice<RecipeInfoView> recipeInfoViews = recipes.map(
recipe -> this.recipeToInfoViewConverter.convert(recipe, user)
);
return ResponseEntity.ok(this.sliceViewService.getSliceView(recipeInfoViews));
}
} }
@PostMapping @PostMapping
@ -108,10 +123,7 @@ public class RecipesController {
recipeSearchBody.getData(), recipeSearchBody.getData(),
RecipeAiSearchBody.class RecipeAiSearchBody.class
); );
final List<RecipeInfoView> results = this.recipeService.aiSearch(spec, user) final List<RecipeInfoView> results = this.recipeService.aiSearch(spec, user);
.stream()
.map(recipe -> this.recipeToInfoViewConverter.convert(recipe, user))
.toList();
return ResponseEntity.ok(Map.of("results", results)); return ResponseEntity.ok(Map.of("results", results));
} else { } else {
throw new IllegalArgumentException("Invalid recipeSearchBody type: " + recipeSearchBody.getType()); throw new IllegalArgumentException("Invalid recipeSearchBody type: " + recipeSearchBody.getType());
@ -123,7 +135,7 @@ public class RecipesController {
@PathVariable String username, @PathVariable String username,
@PathVariable String slug, @PathVariable String slug,
@Nullable @AuthenticationPrincipal User principal @Nullable @AuthenticationPrincipal User principal
) { ) throws RecipeException {
if (principal == null) { if (principal == null) {
throw new AccessDeniedException("Must be logged in to star a recipe."); throw new AccessDeniedException("Must be logged in to star a recipe.");
} }
@ -135,7 +147,7 @@ public class RecipesController {
@PathVariable String username, @PathVariable String username,
@PathVariable String slug, @PathVariable String slug,
@Nullable @AuthenticationPrincipal User principal @Nullable @AuthenticationPrincipal User principal
) { ) throws RecipeException {
if (principal == null) { if (principal == null) {
throw new AccessDeniedException("Must be logged in to get a recipe star."); throw new AccessDeniedException("Must be logged in to get a recipe star.");
} }
@ -152,7 +164,7 @@ public class RecipesController {
@PathVariable String username, @PathVariable String username,
@PathVariable String slug, @PathVariable String slug,
@Nullable @AuthenticationPrincipal User principal @Nullable @AuthenticationPrincipal User principal
) { ) throws RecipeException {
if (principal == null) { if (principal == null) {
throw new AccessDeniedException("Must be logged in to delete a recipe star."); throw new AccessDeniedException("Must be logged in to delete a recipe star.");
} }
@ -166,7 +178,7 @@ public class RecipesController {
@PathVariable String slug, @PathVariable String slug,
Pageable pageable, Pageable pageable,
@Nullable @AuthenticationPrincipal User principal @Nullable @AuthenticationPrincipal User principal
) { ) throws RecipeException {
final Slice<RecipeCommentView> slice = this.recipeCommentService.getComments( final Slice<RecipeCommentView> slice = this.recipeCommentService.getComments(
username, username,
slug, slug,
@ -182,7 +194,7 @@ public class RecipesController {
@PathVariable String slug, @PathVariable String slug,
@RequestBody RecipeCommentCreateBody body, @RequestBody RecipeCommentCreateBody body,
@Nullable @AuthenticationPrincipal User principal @Nullable @AuthenticationPrincipal User principal
) { ) throws RecipeException {
if (principal == null) { if (principal == null) {
throw new AccessDeniedException("Must be logged in to comment on a recipe."); throw new AccessDeniedException("Must be logged in to comment on a recipe.");
} }

View File

@ -1,12 +1,10 @@
package app.mealsmadeeasy.api.recipe.body; package app.mealsmadeeasy.api.recipe.body;
import app.mealsmadeeasy.api.util.SetImageBody; import app.mealsmadeeasy.api.util.SetImageBody;
import lombok.Builder;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.util.List; import java.util.List;
@Builder
public record RecipeDraftUpdateBody( public record RecipeDraftUpdateBody(
@Nullable String slug, @Nullable String slug,
@Nullable String title, @Nullable String title,
@ -18,7 +16,6 @@ public record RecipeDraftUpdateBody(
@Nullable SetImageBody mainImage @Nullable SetImageBody mainImage
) { ) {
@Builder
public record IngredientDraftUpdateBody( public record IngredientDraftUpdateBody(
@Nullable String amount, @Nullable String amount,
String name, String name,

View File

@ -1,13 +1,16 @@
package app.mealsmadeeasy.api.recipe.comment; package app.mealsmadeeasy.api.recipe.comment;
import app.mealsmadeeasy.api.recipe.RecipeException;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice; import org.springframework.data.domain.Slice;
public interface RecipeCommentService { public interface RecipeCommentService {
RecipeComment create(String recipeUsername, String recipeSlug, User owner, RecipeCommentCreateBody body); RecipeComment create(String recipeUsername, String recipeSlug, User owner, RecipeCommentCreateBody body)
RecipeComment get(Integer commentId, User viewer) ; throws RecipeException;
Slice<RecipeCommentView> getComments(String recipeUsername, String recipeSlug, Pageable pageable, User viewer); RecipeComment get(Integer commentId, User viewer) throws RecipeException;
RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) ; Slice<RecipeCommentView> getComments(String recipeUsername, String recipeSlug, Pageable pageable, User viewer)
void delete(Integer commentId, User modifier) ; throws RecipeException;
RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException;
void delete(Integer commentId, User modifier) throws RecipeException;
} }

View File

@ -2,10 +2,9 @@ package app.mealsmadeeasy.api.recipe.comment;
import app.mealsmadeeasy.api.markdown.MarkdownService; import app.mealsmadeeasy.api.markdown.MarkdownService;
import app.mealsmadeeasy.api.recipe.Recipe; import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.recipe.RecipeException;
import app.mealsmadeeasy.api.recipe.RecipeRepository; import app.mealsmadeeasy.api.recipe.RecipeRepository;
import app.mealsmadeeasy.api.user.User; 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.Pageable;
import org.springframework.data.domain.Slice; import org.springframework.data.domain.Slice;
import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PostAuthorize;
@ -40,43 +39,42 @@ public class RecipeCommentServiceImpl implements RecipeCommentService {
String recipeSlug, String recipeSlug,
User commenter, User commenter,
RecipeCommentCreateBody body RecipeCommentCreateBody body
) { ) throws RecipeException {
requireNonNull(commenter); requireNonNull(commenter);
final RecipeComment draft = new RecipeComment(); final RecipeComment draft = new RecipeComment();
draft.setCreated(OffsetDateTime.now()); draft.setCreated(OffsetDateTime.now());
draft.setRawText(body.getText()); draft.setRawText(body.getText());
draft.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(body.getText())); draft.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(body.getText()));
draft.setOwner(commenter); draft.setOwner((User) commenter);
final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(recipeUsername, recipeSlug) final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(recipeUsername, recipeSlug)
.orElseThrow( .orElseThrow(() -> new RecipeException(
() -> new NoSuchEntityWithUsernameAndSlugException(Recipe.class, recipeUsername, recipeSlug) RecipeException.Type.INVALID_USERNAME_OR_SLUG,
); "Invalid username or slug: " + recipeUsername + "/" + recipeSlug
));
draft.setRecipe(recipe); draft.setRecipe(recipe);
return this.recipeCommentRepository.save(draft); return this.recipeCommentRepository.save(draft);
} }
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject.recipe, #viewer)") @PostAuthorize("@recipeSecurity.isViewableBy(returnObject.recipe, #viewer)")
private RecipeComment loadCommentEntity(Integer commentId, User viewer) { private RecipeComment loadCommentEntity(Integer commentId, User viewer) throws RecipeException {
return this.recipeCommentRepository.findById(commentId).orElseThrow( return this.recipeCommentRepository.findById(commentId).orElseThrow(() -> new RecipeException(
() -> new NoSuchEntityWithIdException(RecipeComment.class, commentId) RecipeException.Type.INVALID_COMMENT_ID, "No such RecipeComment for id: " + commentId
); ));
} }
@Override @Override
public RecipeComment get(Integer commentId, User viewer) { public RecipeComment get(Integer commentId, User viewer) throws RecipeException {
return this.loadCommentEntity(commentId, viewer); return this.loadCommentEntity(commentId, viewer);
} }
@Override @Override
@PreAuthorize("@recipeSecurity.isViewableBy(#recipeUsername, #recipeSlug, #viewer)") @PreAuthorize("@recipeSecurity.isViewableBy(#recipeUsername, #recipeSlug, #viewer)")
public Slice<RecipeCommentView> getComments( public Slice<RecipeCommentView> getComments(String recipeUsername, String recipeSlug, Pageable pageable, User viewer) throws RecipeException {
String recipeUsername,
String recipeSlug,
Pageable pageable,
User viewer
) {
final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(recipeUsername, recipeSlug).orElseThrow( final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(recipeUsername, recipeSlug).orElseThrow(
() -> new NoSuchEntityWithUsernameAndSlugException(Recipe.class, recipeUsername, recipeSlug) () -> new RecipeException(
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
"No such Recipe for username/slug: " + recipeUsername + "/" + recipeSlug
)
); );
final Slice<RecipeComment> commentEntities = this.recipeCommentRepository.findAllByRecipe(recipe, pageable); final Slice<RecipeComment> commentEntities = this.recipeCommentRepository.findAllByRecipe(recipe, pageable);
return commentEntities.map(commentEntity -> RecipeCommentView.from( return commentEntities.map(commentEntity -> RecipeCommentView.from(
@ -86,21 +84,21 @@ public class RecipeCommentServiceImpl implements RecipeCommentService {
} }
@Override @Override
public RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) { public RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException {
final RecipeComment entity = this.loadCommentEntity(commentId, viewer); final RecipeComment entity = this.loadCommentEntity(commentId, viewer);
entity.setRawText(spec.getRawText()); entity.setRawText(spec.getRawText());
return this.recipeCommentRepository.save(entity); return this.recipeCommentRepository.save(entity);
} }
@PostAuthorize("@recipeSecurity.isOwner(returnObject.recipe, #modifier)") @PostAuthorize("@recipeSecurity.isOwner(returnObject.recipe, #modifier)")
private RecipeComment loadForDelete(Integer commentId, User modifier) { private RecipeComment loadForDelete(Integer commentId, User modifier) throws RecipeException {
return this.recipeCommentRepository.findById(commentId).orElseThrow(() -> return this.recipeCommentRepository.findById(commentId).orElseThrow(() -> new RecipeException(
new NoSuchEntityWithIdException(RecipeComment.class, commentId) RecipeException.Type.INVALID_COMMENT_ID, "No such RecipeComment for id: " + commentId
); ));
} }
@Override @Override
public void delete(Integer commentId, User modifier) { public void delete(Integer commentId, User modifier) throws RecipeException {
final RecipeComment entityToDelete = this.loadForDelete(commentId, modifier); final RecipeComment entityToDelete = this.loadForDelete(commentId, modifier);
this.recipeCommentRepository.delete(entityToDelete); this.recipeCommentRepository.delete(entityToDelete);
} }

View File

@ -1,40 +0,0 @@
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())
.title(recipeDraft.getTitle())
.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();
}
}

View File

@ -1,6 +1,7 @@
package app.mealsmadeeasy.api.recipe.converter; package app.mealsmadeeasy.api.recipe.converter;
import app.mealsmadeeasy.api.image.Image; import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.image.ImageService; import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.recipe.body.RecipeDraftUpdateBody; import app.mealsmadeeasy.api.recipe.body.RecipeDraftUpdateBody;
import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec;
@ -14,7 +15,7 @@ public class RecipeDraftUpdateBodyToSpecConverter {
private final ImageService imageService; private final ImageService imageService;
public RecipeDraftUpdateSpec convert(RecipeDraftUpdateBody body, User viewer) { public RecipeDraftUpdateSpec convert(RecipeDraftUpdateBody body, User viewer) throws ImageException {
final var b = RecipeDraftUpdateSpec.builder() final var b = RecipeDraftUpdateSpec.builder()
.slug(body.slug()) .slug(body.slug())
.title(body.title()) .title(body.title())

View File

@ -1,44 +0,0 @@
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();
}
}

View File

@ -1,38 +0,0 @@
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();
}
}

View File

@ -1,7 +1,5 @@
package app.mealsmadeeasy.api.recipe.job; package app.mealsmadeeasy.api.recipe.job;
import app.mealsmadeeasy.api.ai.InferenceService;
import app.mealsmadeeasy.api.ai.OcrService;
import app.mealsmadeeasy.api.file.File; import app.mealsmadeeasy.api.file.File;
import app.mealsmadeeasy.api.file.FileService; import app.mealsmadeeasy.api.file.FileService;
import app.mealsmadeeasy.api.job.Job; import app.mealsmadeeasy.api.job.Job;
@ -16,8 +14,13 @@ import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media; import org.springframework.ai.content.Media;
import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.MimeType; import org.springframework.util.MimeType;
@ -49,8 +52,7 @@ public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload>
private final FileService fileService; private final FileService fileService;
private final RecipeService recipeService; private final RecipeService recipeService;
private final OcrService ocrService; private final ChatModel chatModel;
private final InferenceService inferenceService;
private final BeanOutputConverter<RecipeExtraction> extractionConverter = private final BeanOutputConverter<RecipeExtraction> extractionConverter =
new BeanOutputConverter<>(RecipeExtraction.class); new BeanOutputConverter<>(RecipeExtraction.class);
@ -80,7 +82,17 @@ public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload>
.data(new InputStreamResource(sourceFileContent)) .data(new InputStreamResource(sourceFileContent))
.mimeType(MimeType.valueOf(sourceFile.getMimeType())) .mimeType(MimeType.valueOf(sourceFile.getMimeType()))
.build(); .build();
final @Nullable String fullMarkdownText = this.ocrService.readMarkdown(sourceFileMedia);
final Message ocrMessage = UserMessage.builder()
.text("Convert the recipe in the given file to Markdown.")
.media(sourceFileMedia)
.build();
final Prompt ocrPrompt = Prompt.builder()
.messages(ocrMessage)
.chatOptions(OllamaChatOptions.builder().model("deepseek-ocr:latest").build())
.build();
final ChatResponse ocrResponse = this.chatModel.call(ocrPrompt);
final String fullMarkdownText = ocrResponse.getResult().getOutput().getText();
if (fullMarkdownText == null) { if (fullMarkdownText == null) {
throw new RuntimeException("fullMarkdownText from ocr came back null"); throw new RuntimeException("fullMarkdownText from ocr came back null");
} }
@ -120,17 +132,22 @@ public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload>
final Message extractUserMessage = UserMessage.builder() final Message extractUserMessage = UserMessage.builder()
.text(fullMarkdownText) .text(fullMarkdownText)
.build(); .build();
final ChatOptions extractChatOptions = OllamaChatOptions.builder()
final @Nullable RecipeExtraction extraction = this.inferenceService.extract( .model("gemma3:latest")
extractSystemMessage, .format(extractionConverter.getJsonSchemaMap())
extractUserMessage, .build();
this.extractionConverter final Prompt extractPrompt = Prompt.builder()
); .messages(extractSystemMessage, extractUserMessage)
if (extraction == null) { .chatOptions(extractChatOptions)
throw new RuntimeException("extraction returned null"); .build();
final ChatResponse extractResponse = this.chatModel.call(extractPrompt);
final String extractContent = extractResponse.getResult().getOutput().getText();
if (extractContent == null) {
throw new RuntimeException("extractContent from extract came back null");
} }
logger.debug("extract returned:\n{}", extractContent);
logger.debug("extract returned:\n{}", extraction); final RecipeExtraction extraction = this.extractionConverter.convert(extractContent);
// 3. Get draft and set props from extraction // 3. Get draft and set props from extraction
final @Nullable RecipeDraft recipeDraft = this.recipeService.getDraftById(payload.recipeDraftId()); final @Nullable RecipeDraft recipeDraft = this.recipeService.getDraftById(payload.recipeDraftId());

View File

@ -1,15 +1,16 @@
package app.mealsmadeeasy.api.recipe.star; package app.mealsmadeeasy.api.recipe.star;
import app.mealsmadeeasy.api.recipe.RecipeException;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import java.util.Optional; import java.util.Optional;
public interface RecipeStarService { public interface RecipeStarService {
RecipeStar create(Integer recipeId, Integer ownerId); RecipeStar create(Integer recipeId, Integer ownerId);
RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer); RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException;
Optional<RecipeStar> find(String recipeOwnerUsername, String recipeSlug, User starer); Optional<RecipeStar> find(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException;
void delete(Integer recipeId, Integer ownerId); void delete(Integer recipeId, Integer ownerId);
void delete(String recipeOwnerUsername, String recipeSlug, User starer); void delete(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException;
} }

View File

@ -1,6 +1,7 @@
package app.mealsmadeeasy.api.recipe.star; package app.mealsmadeeasy.api.recipe.star;
import app.mealsmadeeasy.api.recipe.Recipe; import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.recipe.RecipeException;
import app.mealsmadeeasy.api.recipe.RecipeService; import app.mealsmadeeasy.api.recipe.RecipeService;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -31,7 +32,7 @@ public class RecipeStarServiceImpl implements RecipeStarService {
} }
@Override @Override
public RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer) { public RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer); final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer);
final Optional<RecipeStar> existing = this.recipeStarRepository.findByRecipeIdAndOwnerId( final Optional<RecipeStar> existing = this.recipeStarRepository.findByRecipeIdAndOwnerId(
recipe.getId(), recipe.getId(),
@ -44,7 +45,7 @@ public class RecipeStarServiceImpl implements RecipeStarService {
} }
@Override @Override
public Optional<RecipeStar> find(String recipeOwnerUsername, String recipeSlug, User starer) { public Optional<RecipeStar> find(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer); final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer);
return this.recipeStarRepository.findByRecipeIdAndOwnerId(recipe.getId(), starer.getId()); return this.recipeStarRepository.findByRecipeIdAndOwnerId(recipe.getId(), starer.getId());
} }
@ -55,7 +56,7 @@ public class RecipeStarServiceImpl implements RecipeStarService {
} }
@Override @Override
public void delete(String recipeOwnerUsername, String recipeSlug, User starer) { public void delete(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer); final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer);
this.delete(recipe.getId(), starer.getId()); this.delete(recipe.getId(), starer.getId());
} }

View File

@ -25,4 +25,26 @@ public record RecipeDraftView(
@Nullable List<RecipeDraft.IngredientDraft> ingredients, @Nullable List<RecipeDraft.IngredientDraft> ingredients,
UserInfoView owner, UserInfoView owner,
@Nullable ImageView mainImage @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();
}
}

View File

@ -1,6 +1,7 @@
package app.mealsmadeeasy.api.recipe.view; package app.mealsmadeeasy.api.recipe.view;
import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.user.view.UserInfoView; import app.mealsmadeeasy.api.user.view.UserInfoView;
import lombok.Builder; import lombok.Builder;
import lombok.Value; import lombok.Value;
@ -11,6 +12,24 @@ import java.time.OffsetDateTime;
@Value @Value
@Builder @Builder
public class RecipeInfoView { 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; Integer id;
OffsetDateTime created; OffsetDateTime created;
OffsetDateTime modified; OffsetDateTime modified;
@ -23,4 +42,5 @@ public class RecipeInfoView {
boolean isPublic; boolean isPublic;
int starCount; int starCount;
@Nullable ImageView mainImage; @Nullable ImageView mainImage;
} }

View File

@ -18,7 +18,7 @@ public class ExceptionHandlers {
.body(new NoSuchEntityWithIdExceptionView<>( .body(new NoSuchEntityWithIdExceptionView<>(
e.getEntityType().getSimpleName(), e.getEntityType().getSimpleName(),
e.getId(), e.getId(),
String.format("No such entity %s with id %s", e.getEntityType().getSimpleName(), e.getId()) "Could not find " + e.getEntityType().getSimpleName() + " with id " + e.getId()
)); ));
} }
@ -32,28 +32,4 @@ public class ExceptionHandlers {
.body(new MustBeLoggedInExceptionView(e.getMessage())); .body(new MustBeLoggedInExceptionView(e.getMessage()));
} }
public record NoSuchEntityWithUsernameAndSlugExceptionView(
String entityName,
String username,
String slug,
String message
) {}
@ExceptionHandler(NoSuchEntityWithUsernameAndSlugException.class)
public ResponseEntity<NoSuchEntityWithUsernameAndSlugExceptionView> 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()
)
));
}
} }

View File

@ -1,12 +0,0 @@
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;
}

View File

@ -1,12 +0,0 @@
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;
}