Compare commits
3 Commits
b19dc42094
...
db9e9eca07
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9e9eca07 | ||
|
|
2d2fa524fa | ||
|
|
547c04fbab |
@ -0,0 +1,117 @@
|
|||||||
|
package app.mealsmadeeasy.api.recipe;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.IntegrationTestsExtension;
|
||||||
|
import app.mealsmadeeasy.api.auth.AuthService;
|
||||||
|
import app.mealsmadeeasy.api.auth.LoginException;
|
||||||
|
import app.mealsmadeeasy.api.user.User;
|
||||||
|
import app.mealsmadeeasy.api.user.UserCreateException;
|
||||||
|
import app.mealsmadeeasy.api.user.UserService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.hamcrest.CoreMatchers.*;
|
||||||
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
|
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.status;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@ExtendWith(IntegrationTestsExtension.class)
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
public class RecipeDraftsControllerIntegrationTests {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AuthService authService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RecipeService recipeService;
|
||||||
|
|
||||||
|
private static final String TEST_PASSWORD = "test";
|
||||||
|
|
||||||
|
private User seedUser() {
|
||||||
|
final String uuid = UUID.randomUUID().toString();
|
||||||
|
try {
|
||||||
|
return this.userService.createUser(uuid, uuid + "@test.com", TEST_PASSWORD);
|
||||||
|
} catch (UserCreateException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getAccessToken(User user) throws LoginException {
|
||||||
|
return this.authService.login(user.getUsername(), TEST_PASSWORD)
|
||||||
|
.getAccessToken()
|
||||||
|
.getToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void whenNoDraft_getReturnsNotFound() throws Exception {
|
||||||
|
final User user = this.seedUser();
|
||||||
|
final String fakeId = UUID.randomUUID().toString();
|
||||||
|
this.mockMvc.perform(
|
||||||
|
get("/recipe-drafts/{fakeId}", fakeId)
|
||||||
|
.header("Authorization", "Bearer " + this.getAccessToken(user))
|
||||||
|
)
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.entityName", is("RecipeDraft")))
|
||||||
|
.andExpect(jsonPath("$.id", is(fakeId)))
|
||||||
|
.andExpect(jsonPath("$.message", is(notNullValue())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void whenNoUser_getReturnsUnauthorized() throws Exception {
|
||||||
|
this.mockMvc.perform(
|
||||||
|
get("/recipe-drafts/{fakeId}", UUID.randomUUID().toString())
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.message", is(notNullValue())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void whenDraftExists_returnsDraftById() throws Exception {
|
||||||
|
final User owner = this.seedUser();
|
||||||
|
final RecipeDraft seeded = this.recipeService.createDraft(owner);
|
||||||
|
this.mockMvc.perform(
|
||||||
|
get("/recipe-drafts/{id}", seeded.getId())
|
||||||
|
.header("Authorization", "Bearer " + this.getAccessToken(owner))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.id", is(seeded.getId().toString())))
|
||||||
|
.andExpect(jsonPath("$.created", is(notNullValue())))
|
||||||
|
.andExpect(jsonPath("$.modified", is(nullValue())))
|
||||||
|
.andExpect(jsonPath("$.state", is(RecipeDraft.State.ENTER_DATA.toString())))
|
||||||
|
.andExpect(jsonPath("$.title", is(nullValue())))
|
||||||
|
.andExpect(jsonPath("$.preparationTime", is(nullValue())))
|
||||||
|
.andExpect(jsonPath("$.cookingTime", is(nullValue())))
|
||||||
|
.andExpect(jsonPath("$.ingredients", is(nullValue())))
|
||||||
|
.andExpect(jsonPath("$.rawText", is(nullValue())))
|
||||||
|
.andExpect(jsonPath("$.owner.username", is(owner.getUsername())))
|
||||||
|
.andExpect(jsonPath("$.mainImage", is(nullValue())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void whenDraftsExist_returnDrafts() throws Exception {
|
||||||
|
final User owner = this.seedUser();
|
||||||
|
final RecipeDraft seed0 = this.recipeService.createDraft(owner);
|
||||||
|
final RecipeDraft seed1 = this.recipeService.createDraft(owner);
|
||||||
|
this.mockMvc.perform(
|
||||||
|
get("/recipe-drafts")
|
||||||
|
.header("Authorization", "Bearer " + this.getAccessToken(owner))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isArray())
|
||||||
|
.andExpect(jsonPath("$", hasSize(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -75,6 +75,9 @@ public class RecipeInferJobIntegrationTests {
|
|||||||
|
|
||||||
final RecipeDraft draftWithInference = this.recipeService.getDraftById(draft.getId());
|
final RecipeDraft draftWithInference = this.recipeService.getDraftById(draft.getId());
|
||||||
|
|
||||||
|
assertThat(draftWithInference.getTitle(), is(notNullValue()));
|
||||||
|
assertThat(draftWithInference.getIngredients(), is(notNullValue()));
|
||||||
|
|
||||||
assertThat(draftWithInference.getInferences(), is(notNullValue()));
|
assertThat(draftWithInference.getInferences(), is(notNullValue()));
|
||||||
final List<RecipeDraft.RecipeDraftInference> inferences = draftWithInference.getInferences();
|
final List<RecipeDraft.RecipeDraftInference> inferences = draftWithInference.getInferences();
|
||||||
|
|
||||||
|
|||||||
@ -11,3 +11,5 @@ app.mealsmadeeasy.api.files.bucketName=files
|
|||||||
# Posted by Iogui, modified by community. See post 'Timeline' for change history
|
# Posted by Iogui, modified by community. See post 'Timeline' for change history
|
||||||
# Retrieved 2025-12-25, License - CC BY-SA 4.0
|
# Retrieved 2025-12-25, License - CC BY-SA 4.0
|
||||||
spring.datasource.hikari.auto-commit=false
|
spring.datasource.hikari.auto-commit=false
|
||||||
|
|
||||||
|
logging.level.app.mealsmadeeasy.api=debug
|
||||||
@ -29,6 +29,13 @@ public class RecipeDraft {
|
|||||||
private String rawText;
|
private String rawText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class IngredientDraft {
|
||||||
|
private @Nullable String amount;
|
||||||
|
private String name;
|
||||||
|
private @Nullable String notes;
|
||||||
|
}
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.UUID)
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
@Column(nullable = false, unique = true, updatable = false)
|
@Column(nullable = false, unique = true, updatable = false)
|
||||||
@ -48,6 +55,10 @@ public class RecipeDraft {
|
|||||||
private @Nullable Integer totalTime;
|
private @Nullable Integer totalTime;
|
||||||
private @Nullable String rawText;
|
private @Nullable String rawText;
|
||||||
|
|
||||||
|
@Type(JsonBinaryType.class)
|
||||||
|
@Column(columnDefinition = "jsonb")
|
||||||
|
private @Nullable List<IngredientDraft> ingredients;
|
||||||
|
|
||||||
@ManyToOne(optional = false)
|
@ManyToOne(optional = false)
|
||||||
@JoinColumn(name = "owner_id", nullable = false)
|
@JoinColumn(name = "owner_id", nullable = false)
|
||||||
private User owner;
|
private User owner;
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
package app.mealsmadeeasy.api.recipe;
|
package app.mealsmadeeasy.api.recipe;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.user.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface RecipeDraftRepository extends JpaRepository<RecipeDraft, UUID> {}
|
public interface RecipeDraftRepository extends JpaRepository<RecipeDraft, UUID> {
|
||||||
|
|
||||||
|
@Query("SELECT r FROM RecipeDraft r WHERE :user = r.owner")
|
||||||
|
List<RecipeDraft> findAllViewableBy(User user);
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
package app.mealsmadeeasy.api.recipe;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.user.User;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RecipeDraftSecurity {
|
||||||
|
|
||||||
|
private final RecipeDraftRepository recipeDraftRepository;
|
||||||
|
|
||||||
|
public boolean isViewableBy(RecipeDraft recipeDraft, @Nullable User viewer) {
|
||||||
|
if (viewer == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return recipeDraft.getOwner().getId().equals(viewer.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUpdatableBy(UUID id, @Nullable User modifier) {
|
||||||
|
if (modifier == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final Optional<RecipeDraft> maybeTarget = this.recipeDraftRepository.findById(id);
|
||||||
|
if (maybeTarget.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final RecipeDraft target = maybeTarget.get();
|
||||||
|
return target.getOwner().getId().equals(modifier.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -2,18 +2,27 @@ 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.converter.RecipeDraftUpdateBodyToSpecConverter;
|
||||||
|
import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec;
|
||||||
|
import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
|
||||||
|
import app.mealsmadeeasy.api.recipe.view.RecipeDraftView;
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
|
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;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.*;
|
||||||
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 org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/recipe-drafts")
|
@RequestMapping("/recipe-drafts")
|
||||||
@ -22,15 +31,58 @@ 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;
|
||||||
|
|
||||||
@PutMapping("/ai")
|
private @Nullable ImageView getImageView(RecipeDraft recipeDraft, User viewer) {
|
||||||
public ResponseEntity<RecipeDraft> createRecipeDraft(
|
return recipeDraft.getMainImage() != null
|
||||||
|
? this.imageService.toImageView(recipeDraft.getMainImage(), viewer)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<RecipeDraftView>> getAllDraftsForUser(@AuthenticationPrincipal User user) {
|
||||||
|
if (user == null) {
|
||||||
|
throw new MustBeLoggedInException();
|
||||||
|
}
|
||||||
|
final List<RecipeDraft> recipeDrafts = this.recipeService.getDrafts(user);
|
||||||
|
return ResponseEntity.ok(recipeDrafts.stream().map(recipeDraft -> {
|
||||||
|
final @Nullable ImageView mainImage = this.getImageView(recipeDraft, user);
|
||||||
|
return RecipeDraftView.from(recipeDraft, mainImage);
|
||||||
|
}).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<RecipeDraftView> getRecipeDraft(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal User viewer
|
||||||
|
) {
|
||||||
|
if (viewer == null) {
|
||||||
|
throw new MustBeLoggedInException();
|
||||||
|
}
|
||||||
|
final RecipeDraft recipeDraft = this.recipeService.getDraftByIdWithViewer(id, viewer);
|
||||||
|
final @Nullable ImageView imageView = this.getImageView(recipeDraft, viewer);
|
||||||
|
return ResponseEntity.ok(RecipeDraftView.from(recipeDraft, imageView));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/manual")
|
||||||
|
public ResponseEntity<RecipeDraftView> createManualDraft(@AuthenticationPrincipal User owner) {
|
||||||
|
if (owner == null) {
|
||||||
|
throw new MustBeLoggedInException();
|
||||||
|
}
|
||||||
|
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
|
||||||
|
final ImageView imageView = this.getImageView(recipeDraft, owner);
|
||||||
|
return ResponseEntity.ok(RecipeDraftView.from(recipeDraft, imageView));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/ai")
|
||||||
|
public ResponseEntity<RecipeDraftView> createAiDraft(
|
||||||
@AuthenticationPrincipal User owner,
|
@AuthenticationPrincipal User owner,
|
||||||
@RequestParam MultipartFile sourceFile,
|
@RequestParam MultipartFile sourceFile,
|
||||||
@RequestParam String sourceFileName
|
@RequestParam String sourceFileName
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
if (owner == null) {
|
if (owner == null) {
|
||||||
throw new IllegalArgumentException("Must be logged in to create a RecipeDraft.");
|
throw new MustBeLoggedInException();
|
||||||
}
|
}
|
||||||
final File file = this.fileService.create(
|
final File file = this.fileService.create(
|
||||||
sourceFile.getInputStream(),
|
sourceFile.getInputStream(),
|
||||||
@ -39,7 +91,48 @@ 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(recipeDraft);
|
final @Nullable ImageView mainImageView = this.getImageView(recipeDraft, owner);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(RecipeDraftView.from(recipeDraft, mainImageView));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<RecipeDraftView> updateRecipeDraft(
|
||||||
|
@AuthenticationPrincipal User modifier,
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@RequestBody RecipeDraftUpdateBody updateBody
|
||||||
|
) throws ImageException {
|
||||||
|
if (modifier == null) {
|
||||||
|
throw new MustBeLoggedInException();
|
||||||
|
}
|
||||||
|
final RecipeDraftUpdateSpec spec = this.updateBodyToSpecConverter.convert(updateBody, modifier);
|
||||||
|
final RecipeDraft updated = this.recipeService.updateDraft(id, spec, modifier);
|
||||||
|
final @Nullable ImageView imageView = this.getImageView(updated, modifier);
|
||||||
|
return ResponseEntity.ok(RecipeDraftView.from(updated, imageView));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> deleteRecipeDraft(
|
||||||
|
@AuthenticationPrincipal User modifier,
|
||||||
|
@PathVariable UUID id
|
||||||
|
) {
|
||||||
|
if (modifier == null) {
|
||||||
|
throw new MustBeLoggedInException();
|
||||||
|
}
|
||||||
|
this.recipeService.deleteDraft(id, modifier);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}")
|
||||||
|
public ResponseEntity<FullRecipeView> publishRecipeDraft(
|
||||||
|
@AuthenticationPrincipal User modifier,
|
||||||
|
@PathVariable UUID id
|
||||||
|
) {
|
||||||
|
if (modifier == null) {
|
||||||
|
throw new MustBeLoggedInException();
|
||||||
|
}
|
||||||
|
final Recipe recipe = this.recipeService.publishDraft(id, modifier);
|
||||||
|
final FullRecipeView view = this.recipeService.toFullRecipeView(recipe, false, modifier);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,11 +11,13 @@ import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody;
|
|||||||
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler;
|
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler;
|
||||||
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobPayload;
|
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobPayload;
|
||||||
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
|
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
|
||||||
|
import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec;
|
||||||
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
|
import app.mealsmadeeasy.api.recipe.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.FullRecipeView;
|
||||||
import app.mealsmadeeasy.api.recipe.view.RecipeInfoView;
|
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 jakarta.transaction.Transactional;
|
import jakarta.transaction.Transactional;
|
||||||
import org.jetbrains.annotations.ApiStatus;
|
import org.jetbrains.annotations.ApiStatus;
|
||||||
import org.jetbrains.annotations.Contract;
|
import org.jetbrains.annotations.Contract;
|
||||||
@ -326,6 +328,10 @@ public class RecipeService {
|
|||||||
return viewer.getUsername().equals(username);
|
return viewer.getUsername().equals(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<RecipeDraft> getDrafts(User viewer) {
|
||||||
|
return this.recipeDraftRepository.findAllViewableBy(viewer);
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@ -359,8 +365,81 @@ public class RecipeService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostAuthorize("@recipeDraftSecurity.isViewableBy(returnObject, #viewer)")
|
||||||
|
public RecipeDraft getDraftByIdWithViewer(UUID id, @Nullable User viewer) {
|
||||||
|
return this.recipeDraftRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new NoSuchEntityWithIdException(RecipeDraft.class, id));
|
||||||
|
}
|
||||||
|
|
||||||
public RecipeDraft saveDraft(RecipeDraft recipeDraft) {
|
public RecipeDraft saveDraft(RecipeDraft recipeDraft) {
|
||||||
return this.recipeDraftRepository.save(recipeDraft);
|
return this.recipeDraftRepository.save(recipeDraft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("@recipeDraftSecurity.isUpdatableBy(#id, #modifier)")
|
||||||
|
public RecipeDraft updateDraft(UUID id, RecipeDraftUpdateSpec spec, User modifier) {
|
||||||
|
final RecipeDraft recipeDraft = this.recipeDraftRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new NoSuchEntityWithIdException(RecipeDraft.class, id));
|
||||||
|
if (spec.slug() != null) {
|
||||||
|
recipeDraft.setSlug(spec.slug());
|
||||||
|
}
|
||||||
|
if (spec.title() != null) {
|
||||||
|
recipeDraft.setTitle(spec.title());
|
||||||
|
}
|
||||||
|
if (spec.preparationTime() != null) {
|
||||||
|
recipeDraft.setPreparationTime(spec.preparationTime());
|
||||||
|
}
|
||||||
|
if (spec.cookingTime() != null) {
|
||||||
|
recipeDraft.setCookingTime(spec.cookingTime());
|
||||||
|
}
|
||||||
|
if (spec.totalTime() != null) {
|
||||||
|
recipeDraft.setTotalTime(spec.totalTime());
|
||||||
|
}
|
||||||
|
if (spec.rawText() != null) {
|
||||||
|
recipeDraft.setRawText(spec.rawText());
|
||||||
|
}
|
||||||
|
if (spec.ingredients() != null) {
|
||||||
|
final List<RecipeDraft.IngredientDraft> ingredients = spec.ingredients()
|
||||||
|
.stream()
|
||||||
|
.map(ingredientSpec -> {
|
||||||
|
final var ingredient = new RecipeDraft.IngredientDraft();
|
||||||
|
ingredient.setAmount(ingredientSpec.amount());
|
||||||
|
ingredient.setName(ingredientSpec.name());
|
||||||
|
ingredient.setNotes(ingredientSpec.notes());
|
||||||
|
return ingredient;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
recipeDraft.setIngredients(ingredients);
|
||||||
|
}
|
||||||
|
if (spec.mainImage() != null) {
|
||||||
|
recipeDraft.setMainImage(spec.mainImage());
|
||||||
|
}
|
||||||
|
recipeDraft.setModified(OffsetDateTime.now());
|
||||||
|
return this.recipeDraftRepository.save(recipeDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("@recipeDraftSecurity.isUpdatableBy(#id, #modifier)")
|
||||||
|
public void deleteDraft(UUID id, User modifier) {
|
||||||
|
this.recipeDraftRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("@recipeDraftSecurity.isUpdatableBy(#draftId, #modifier)")
|
||||||
|
@Transactional
|
||||||
|
public Recipe publishDraft(UUID draftId, User modifier) {
|
||||||
|
final RecipeDraft recipeDraft = this.recipeDraftRepository.findById(draftId)
|
||||||
|
.orElseThrow(() -> new NoSuchEntityWithIdException(RecipeDraft.class, draftId));
|
||||||
|
final RecipeCreateSpec spec = RecipeCreateSpec.builder()
|
||||||
|
.slug(recipeDraft.getSlug())
|
||||||
|
.title(recipeDraft.getTitle())
|
||||||
|
.preparationTime(recipeDraft.getPreparationTime())
|
||||||
|
.cookingTime(recipeDraft.getCookingTime())
|
||||||
|
.totalTime(recipeDraft.getTotalTime())
|
||||||
|
.rawText(recipeDraft.getRawText())
|
||||||
|
.isPublic(false)
|
||||||
|
.mainImage(recipeDraft.getMainImage())
|
||||||
|
.build();
|
||||||
|
final Recipe recipe = this.create(recipeDraft.getOwner(), spec);
|
||||||
|
this.recipeDraftRepository.deleteById(draftId); // delete old draft
|
||||||
|
return recipe;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
package app.mealsmadeeasy.api.recipe.body;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.util.SetImageBody;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record RecipeDraftUpdateBody(
|
||||||
|
@Nullable String slug,
|
||||||
|
@Nullable String title,
|
||||||
|
@Nullable Integer preparationTime,
|
||||||
|
@Nullable Integer cookingTime,
|
||||||
|
@Nullable Integer totalTime,
|
||||||
|
@Nullable String rawText,
|
||||||
|
@Nullable List<IngredientDraftUpdateBody> ingredients,
|
||||||
|
@Nullable SetImageBody mainImage
|
||||||
|
) {
|
||||||
|
|
||||||
|
public record IngredientDraftUpdateBody(
|
||||||
|
@Nullable String amount,
|
||||||
|
String name,
|
||||||
|
@Nullable String notes
|
||||||
|
) {}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package app.mealsmadeeasy.api.recipe.converter;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.image.Image;
|
||||||
|
import app.mealsmadeeasy.api.image.ImageException;
|
||||||
|
import app.mealsmadeeasy.api.image.ImageService;
|
||||||
|
import app.mealsmadeeasy.api.recipe.body.RecipeDraftUpdateBody;
|
||||||
|
import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec;
|
||||||
|
import app.mealsmadeeasy.api.user.User;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RecipeDraftUpdateBodyToSpecConverter {
|
||||||
|
|
||||||
|
private final ImageService imageService;
|
||||||
|
|
||||||
|
public RecipeDraftUpdateSpec convert(RecipeDraftUpdateBody body, User viewer) throws ImageException {
|
||||||
|
final var b = RecipeDraftUpdateSpec.builder()
|
||||||
|
.slug(body.slug())
|
||||||
|
.title(body.title())
|
||||||
|
.preparationTime(body.preparationTime())
|
||||||
|
.cookingTime(body.cookingTime())
|
||||||
|
.totalTime(body.totalTime())
|
||||||
|
.rawText(body.rawText());
|
||||||
|
if (body.ingredients() != null) {
|
||||||
|
final var ingredients = body.ingredients().stream()
|
||||||
|
.map(ingredientBody -> RecipeDraftUpdateSpec.IngredientDraftUpdateSpec.builder()
|
||||||
|
.amount(ingredientBody.amount())
|
||||||
|
.name(ingredientBody.name())
|
||||||
|
.notes(ingredientBody.notes())
|
||||||
|
.build()
|
||||||
|
).toList();
|
||||||
|
b.ingredients(ingredients);
|
||||||
|
}
|
||||||
|
if (body.mainImage() != null) {
|
||||||
|
final Image mainImage = this.imageService.getByUsernameAndFilename(
|
||||||
|
body.mainImage().username(),
|
||||||
|
body.mainImage().userFilename(),
|
||||||
|
viewer
|
||||||
|
);
|
||||||
|
b.mainImage(mainImage);
|
||||||
|
}
|
||||||
|
return b.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,14 +6,20 @@ import app.mealsmadeeasy.api.job.Job;
|
|||||||
import app.mealsmadeeasy.api.job.JobHandler;
|
import app.mealsmadeeasy.api.job.JobHandler;
|
||||||
import app.mealsmadeeasy.api.recipe.RecipeDraft;
|
import app.mealsmadeeasy.api.recipe.RecipeDraft;
|
||||||
import app.mealsmadeeasy.api.recipe.RecipeService;
|
import app.mealsmadeeasy.api.recipe.RecipeService;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
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.UserMessage;
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
import org.springframework.ai.chat.model.ChatResponse;
|
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.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.ollama.api.OllamaChatOptions;
|
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;
|
||||||
@ -23,16 +29,32 @@ import java.io.IOException;
|
|||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload> {
|
public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload> {
|
||||||
|
|
||||||
|
private record RecipeExtraction(
|
||||||
|
@JsonProperty(required = true) String title,
|
||||||
|
@JsonProperty(required = true) List<IngredientExtraction> ingredients
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private record IngredientExtraction(
|
||||||
|
String amount,
|
||||||
|
@JsonProperty(required = true) String name,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
|
|
||||||
public static final String JOB_KEY = "RECIPE_INFER_JOB";
|
public static final String JOB_KEY = "RECIPE_INFER_JOB";
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(RecipeInferJobHandler.class);
|
||||||
|
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final RecipeService recipeService;
|
private final RecipeService recipeService;
|
||||||
private final ChatModel chatModel;
|
private final ChatModel chatModel;
|
||||||
|
private final BeanOutputConverter<RecipeExtraction> extractionConverter =
|
||||||
|
new BeanOutputConverter<>(RecipeExtraction.class);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Class<RecipeInferJobPayload> getPayloadType() {
|
public Class<RecipeInferJobPayload> getPayloadType() {
|
||||||
@ -46,6 +68,7 @@ public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload>
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void handle(Job job, RecipeInferJobPayload payload) {
|
public void handle(Job job, RecipeInferJobPayload payload) {
|
||||||
|
logger.debug("Starting recipe inference job {}", job.getId());
|
||||||
final File sourceFile = this.fileService.getById(payload.fileId());
|
final File sourceFile = this.fileService.getById(payload.fileId());
|
||||||
final InputStream sourceFileContent;
|
final InputStream sourceFileContent;
|
||||||
try {
|
try {
|
||||||
@ -54,13 +77,14 @@ public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload>
|
|||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. OCR of recipe image
|
||||||
final Media sourceFileMedia = Media.builder()
|
final Media sourceFileMedia = Media.builder()
|
||||||
.data(new InputStreamResource(sourceFileContent))
|
.data(new InputStreamResource(sourceFileContent))
|
||||||
.mimeType(MimeType.valueOf(sourceFile.getMimeType()))
|
.mimeType(MimeType.valueOf(sourceFile.getMimeType()))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
final Message ocrMessage = UserMessage.builder()
|
final Message ocrMessage = UserMessage.builder()
|
||||||
.text("Convert the recipe in the image to Markdown.")
|
.text("Convert the recipe in the given file to Markdown.")
|
||||||
.media(sourceFileMedia)
|
.media(sourceFileMedia)
|
||||||
.build();
|
.build();
|
||||||
final Prompt ocrPrompt = Prompt.builder()
|
final Prompt ocrPrompt = Prompt.builder()
|
||||||
@ -69,26 +93,103 @@ public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload>
|
|||||||
.build();
|
.build();
|
||||||
final ChatResponse ocrResponse = this.chatModel.call(ocrPrompt);
|
final ChatResponse ocrResponse = this.chatModel.call(ocrPrompt);
|
||||||
final String fullMarkdownText = ocrResponse.getResult().getOutput().getText();
|
final String fullMarkdownText = ocrResponse.getResult().getOutput().getText();
|
||||||
|
if (fullMarkdownText == null) {
|
||||||
|
throw new RuntimeException("fullMarkdownText from ocr came back null");
|
||||||
|
}
|
||||||
|
|
||||||
// get recipe draft
|
logger.debug("ocr returned:\n{}", fullMarkdownText);
|
||||||
|
|
||||||
|
// 2. Extract title and ingredients
|
||||||
|
final Message extractSystemMessage = SystemMessage.builder()
|
||||||
|
.text("""
|
||||||
|
Extract from the given Markdown food recipe the title and ingredients.
|
||||||
|
For each ingredient, you must provide the name; amount and notes are optional.
|
||||||
|
|
||||||
|
Here is an example of a full extraction:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Delicious Recipe",
|
||||||
|
"ingredients": [
|
||||||
|
{
|
||||||
|
"amount": "1 tablespoon",
|
||||||
|
"name": "garlic powder"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": "1 lb",
|
||||||
|
"name": "tofu",
|
||||||
|
"notes": "pressed and dried"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "salt and pepper"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
""".stripIndent().trim()
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
final Message extractUserMessage = UserMessage.builder()
|
||||||
|
.text(fullMarkdownText)
|
||||||
|
.build();
|
||||||
|
final ChatOptions extractChatOptions = OllamaChatOptions.builder()
|
||||||
|
.model("gemma3:latest")
|
||||||
|
.format(extractionConverter.getJsonSchemaMap())
|
||||||
|
.build();
|
||||||
|
final Prompt extractPrompt = Prompt.builder()
|
||||||
|
.messages(extractSystemMessage, extractUserMessage)
|
||||||
|
.chatOptions(extractChatOptions)
|
||||||
|
.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);
|
||||||
|
|
||||||
|
final RecipeExtraction extraction = this.extractionConverter.convert(extractContent);
|
||||||
|
|
||||||
|
// 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());
|
||||||
if (recipeDraft == null) {
|
if (recipeDraft == null) {
|
||||||
throw new RuntimeException("Recipe draft not found");
|
throw new RuntimeException("Recipe draft not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// set props on draft from ai output, etc.
|
recipeDraft.setTitle(extraction.title());
|
||||||
|
|
||||||
|
// todo: calculate slug
|
||||||
|
|
||||||
|
// ingredients
|
||||||
|
if (recipeDraft.getIngredients() == null) {
|
||||||
|
recipeDraft.setIngredients(new ArrayList<>());
|
||||||
|
}
|
||||||
|
recipeDraft.getIngredients().addAll(extraction.ingredients().stream()
|
||||||
|
.map(extractedIngredient -> {
|
||||||
|
final RecipeDraft.IngredientDraft ingredientDraft = new RecipeDraft.IngredientDraft();
|
||||||
|
ingredientDraft.setAmount(extractedIngredient.amount());
|
||||||
|
ingredientDraft.setName(extractedIngredient.name());
|
||||||
|
ingredientDraft.setNotes(extractedIngredient.notes());
|
||||||
|
return ingredientDraft;
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
|
||||||
|
// other props
|
||||||
recipeDraft.setRawText(fullMarkdownText);
|
recipeDraft.setRawText(fullMarkdownText);
|
||||||
recipeDraft.setState(RecipeDraft.State.ENTER_DATA);
|
recipeDraft.setState(RecipeDraft.State.ENTER_DATA);
|
||||||
|
|
||||||
|
// save inference for later
|
||||||
if (recipeDraft.getInferences() == null) {
|
if (recipeDraft.getInferences() == null) {
|
||||||
recipeDraft.setInferences(new ArrayList<>());
|
recipeDraft.setInferences(new ArrayList<>());
|
||||||
}
|
}
|
||||||
final RecipeDraft.RecipeDraftInference inference = new RecipeDraft.RecipeDraftInference();
|
final RecipeDraft.RecipeDraftInference inference = new RecipeDraft.RecipeDraftInference();
|
||||||
inference.setTitle("TODO: inferred title");
|
inference.setTitle(extraction.title());
|
||||||
inference.setRawText(fullMarkdownText);
|
inference.setRawText(fullMarkdownText);
|
||||||
inference.setInferredAt(OffsetDateTime.now());
|
inference.setInferredAt(OffsetDateTime.now());
|
||||||
recipeDraft.getInferences().add(inference);
|
recipeDraft.getInferences().add(inference);
|
||||||
|
|
||||||
|
logger.debug("Recipe infer job completed, saving to db...");
|
||||||
|
|
||||||
this.recipeService.saveDraft(recipeDraft);
|
this.recipeService.saveDraft(recipeDraft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
package app.mealsmadeeasy.api.recipe.spec;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.image.Image;
|
||||||
|
import lombok.Builder;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public record RecipeDraftUpdateSpec(
|
||||||
|
@Nullable String slug,
|
||||||
|
@Nullable String title,
|
||||||
|
@Nullable Integer preparationTime,
|
||||||
|
@Nullable Integer cookingTime,
|
||||||
|
@Nullable Integer totalTime,
|
||||||
|
@Nullable String rawText,
|
||||||
|
@Nullable List<IngredientDraftUpdateSpec> ingredients,
|
||||||
|
@Nullable Image mainImage
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public record IngredientDraftUpdateSpec(
|
||||||
|
@Nullable String amount,
|
||||||
|
String name,
|
||||||
|
@Nullable String notes
|
||||||
|
) {}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
package app.mealsmadeeasy.api.recipe.view;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.image.view.ImageView;
|
||||||
|
import app.mealsmadeeasy.api.recipe.RecipeDraft;
|
||||||
|
import app.mealsmadeeasy.api.user.view.UserInfoView;
|
||||||
|
import lombok.Builder;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public record RecipeDraftView(
|
||||||
|
UUID id,
|
||||||
|
OffsetDateTime created,
|
||||||
|
@Nullable OffsetDateTime modified,
|
||||||
|
RecipeDraft.State state,
|
||||||
|
@Nullable String slug,
|
||||||
|
@Nullable String title,
|
||||||
|
@Nullable Integer preparationTime,
|
||||||
|
@Nullable Integer cookingTime,
|
||||||
|
@Nullable Integer totalTime,
|
||||||
|
@Nullable String rawText,
|
||||||
|
@Nullable List<RecipeDraft.IngredientDraft> ingredients,
|
||||||
|
UserInfoView owner,
|
||||||
|
@Nullable ImageView mainImage
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static RecipeDraftView from(
|
||||||
|
RecipeDraft recipeDraft,
|
||||||
|
@Nullable ImageView mainImageView
|
||||||
|
) {
|
||||||
|
return RecipeDraftView.builder()
|
||||||
|
.id(recipeDraft.getId())
|
||||||
|
.created(recipeDraft.getCreated())
|
||||||
|
.modified(recipeDraft.getModified())
|
||||||
|
.state(recipeDraft.getState())
|
||||||
|
.slug(recipeDraft.getSlug())
|
||||||
|
.preparationTime(recipeDraft.getPreparationTime())
|
||||||
|
.cookingTime(recipeDraft.getCookingTime())
|
||||||
|
.totalTime(recipeDraft.getTotalTime())
|
||||||
|
.rawText(recipeDraft.getRawText())
|
||||||
|
.ingredients(recipeDraft.getIngredients())
|
||||||
|
.owner(UserInfoView.from(recipeDraft.getOwner()))
|
||||||
|
.mainImage(mainImageView)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package app.mealsmadeeasy.api.util;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
|
||||||
|
@ControllerAdvice
|
||||||
|
public class ExceptionHandlers {
|
||||||
|
|
||||||
|
public record NoSuchEntityWithIdExceptionView<T>(String entityName, T id, String message) {}
|
||||||
|
|
||||||
|
@ExceptionHandler(NoSuchEntityWithIdException.class)
|
||||||
|
public ResponseEntity<NoSuchEntityWithIdExceptionView<?>> handleNoSuchEntityWithIdException(
|
||||||
|
NoSuchEntityWithIdException e
|
||||||
|
) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(new NoSuchEntityWithIdExceptionView<>(
|
||||||
|
e.getEntityType().getSimpleName(),
|
||||||
|
e.getId(),
|
||||||
|
"Could not find " + e.getEntityType().getSimpleName() + " with id " + e.getId()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MustBeLoggedInExceptionView(String message) {}
|
||||||
|
|
||||||
|
@ExceptionHandler(MustBeLoggedInException.class)
|
||||||
|
public ResponseEntity<MustBeLoggedInExceptionView> handleMustBeLoggedInException(
|
||||||
|
MustBeLoggedInException e
|
||||||
|
) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
|
.body(new MustBeLoggedInExceptionView(e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package app.mealsmadeeasy.api.util;
|
||||||
|
|
||||||
|
public class MustBeLoggedInException extends RuntimeException {
|
||||||
|
|
||||||
|
public MustBeLoggedInException() {
|
||||||
|
super("Must be logged in to perform the requested operation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public MustBeLoggedInException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package app.mealsmadeeasy.api.util;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class NoSuchEntityWithIdException extends RuntimeException {
|
||||||
|
|
||||||
|
private final Class<?> entityType;
|
||||||
|
private final Object id;
|
||||||
|
|
||||||
|
public NoSuchEntityWithIdException(Class<?> entityType, Object id) {
|
||||||
|
super("Could not find entity " + entityType.getSimpleName() + " with id " + id);
|
||||||
|
this.entityType = entityType;
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package app.mealsmadeeasy.api.util;
|
||||||
|
|
||||||
|
public record SetImageBody(
|
||||||
|
String username,
|
||||||
|
String userFilename
|
||||||
|
) {}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE recipe_draft ADD COLUMN ingredients JSONB;
|
||||||
Loading…
Reference in New Issue
Block a user