Compare commits

..

No commits in common. "7f985f3434538f7ac007a24c76f33119d99d98b5" and "0ad45adac1645cc3b0d93f1a979e697b7134b4df" have entirely different histories.

92 changed files with 2035 additions and 1994 deletions

View File

@ -80,9 +80,6 @@ dependencies {
implementation 'org.jsoup:jsoup:1.21.2'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1'
// Source: https://mvnrepository.com/artifact/io.hypersistence/hypersistence-utils-hibernate-63
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.14.1'
implementation 'io.minio:minio:8.6.0'
compileOnly 'org.jetbrains:annotations:26.0.2-1'
@ -101,10 +98,6 @@ dependencies {
testImplementation 'org.testcontainers:junit-jupiter:1.21.4'
testImplementation 'org.testcontainers:postgresql:1.21.4'
testImplementation 'org.testcontainers:minio:1.21.4'
testImplementation 'org.testcontainers:testcontainers-ollama:2.0.3'
// Source: https://mvnrepository.com/artifact/org.awaitility/awaitility
testImplementation("org.awaitility:awaitility:4.3.0")
testFixturesImplementation 'org.hamcrest:hamcrest:3.0'
}

View File

@ -3,9 +3,9 @@ package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.auth.AuthService;
import app.mealsmadeeasy.api.auth.LoginException;
import app.mealsmadeeasy.api.image.body.ImageUpdateBody;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.image.body.ImageUpdateInfoBody;
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService;
@ -89,7 +89,7 @@ public class ImageControllerTests {
}
}
private Image seedImage(User owner, ImageCreateSpec spec) {
private Image seedImage(User owner, ImageCreateInfoSpec spec) {
try {
return this.imageService.create(
owner,
@ -104,7 +104,7 @@ public class ImageControllerTests {
}
private Image seedImage(User owner) {
return this.seedImage(owner, ImageCreateSpec.builder().build());
return this.seedImage(owner, new ImageCreateInfoSpec());
}
private static String getImageUrl(User owner, Image image) {
@ -122,9 +122,8 @@ public class ImageControllerTests {
@Test
public void getPublicImageNoPrincipal() throws Exception {
final User owner = this.seedUser();
final ImageCreateSpec spec = ImageCreateSpec.builder()
.isPublic(true)
.build();
final ImageCreateInfoSpec spec = new ImageCreateInfoSpec();
spec.setPublic(true);
final Image image = this.seedImage(owner, spec);
// Assert bytes the same and proper mime type
@ -162,9 +161,8 @@ public class ImageControllerTests {
final Image image = this.seedImage(owner);
// add viewer
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
.viewersToAdd(Set.of(viewer))
.build();
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setViewersToAdd(Set.of(viewer));
this.imageService.update(image, owner, spec);
this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(viewer));
@ -197,9 +195,8 @@ public class ImageControllerTests {
final User viewer = this.seedUser();
// add viewer
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
.viewersToAdd(Set.of(viewer))
.build();
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setViewersToAdd(Set.of(viewer));
this.imageService.update(image, owner, spec);
this.mockMvc.perform(get(getImageUrl(owner, image))).andExpect(status().isForbidden());
@ -234,7 +231,7 @@ public class ImageControllerTests {
.andExpect(jsonPath("$.mimeType").value("image/svg+xml"))
.andExpect(jsonPath("$.alt").value("HAL 9000"))
.andExpect(jsonPath("$.caption").value("HAL 9000, from 2001: A Space Odyssey"))
.andExpect(jsonPath("$.public").value(true))
.andExpect(jsonPath("$.isPublic").value(true))
.andExpect(jsonPath("$.owner.username").value(owner.getUsername()))
.andExpect(jsonPath("$.owner.id").value(owner.getId()))
.andExpect(jsonPath("$.viewers").value(empty()));
@ -246,7 +243,7 @@ public class ImageControllerTests {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
final ImageUpdateBody body = new ImageUpdateBody();
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
body.setAlt("HAL 9000");
this.mockMvc.perform(
post(getImageUrl(owner, image))
@ -264,7 +261,7 @@ public class ImageControllerTests {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
final ImageUpdateBody body = new ImageUpdateBody();
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
body.setCaption("HAL 9000 from 2001: A Space Odyssey");
this.mockMvc.perform(
post(getImageUrl(owner, image))
@ -282,8 +279,8 @@ public class ImageControllerTests {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
final ImageUpdateBody body = new ImageUpdateBody();
body.setIsPublic(true);
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
body.setPublic(true);
this.mockMvc.perform(
post(getImageUrl(owner, image))
.contentType(MediaType.APPLICATION_JSON)
@ -292,7 +289,7 @@ public class ImageControllerTests {
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.modified").value(notNullValue()))
.andExpect(jsonPath("$.public").value(true));
.andExpect(jsonPath("$.isPublic").value(true));
}
@Test
@ -302,7 +299,7 @@ public class ImageControllerTests {
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
final ImageUpdateBody body = new ImageUpdateBody();
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
final Set<String> viewerUsernames = Set.of(viewerToAdd.getUsername());
body.setViewersToAdd(viewerUsernames);
@ -324,9 +321,8 @@ public class ImageControllerTests {
final User owner = this.seedUser();
final User viewer = this.seedUser();
final Image image = this.seedImage(owner);
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
.viewersToAdd(Set.of(viewer))
.build();
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setViewersToAdd(Set.of(viewer));
this.imageService.update(image, owner, spec);
return new OwnerViewerImage(owner, viewer, image);
}
@ -336,7 +332,7 @@ public class ImageControllerTests {
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
final String accessToken = this.getAccessToken(ownerViewerImage.owner());
final ImageUpdateBody body = new ImageUpdateBody();
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
body.setViewersToRemove(Set.of(ownerViewerImage.viewer().getUsername()));
this.mockMvc.perform(
@ -354,7 +350,7 @@ public class ImageControllerTests {
public void clearAllViewers() throws Exception {
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
final String accessToken = this.getAccessToken(ownerViewerImage.owner());
final ImageUpdateBody body = new ImageUpdateBody();
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
body.setClearAllViewers(true);
this.mockMvc.perform(
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
@ -371,7 +367,7 @@ public class ImageControllerTests {
public void updateInfoByViewerForbidden() throws Exception {
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
final String accessToken = this.getAccessToken(ownerViewerImage.viewer()); // viewer
final ImageUpdateBody body = new ImageUpdateBody();
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
this.mockMvc.perform(
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
.contentType(MediaType.APPLICATION_JSON)

View File

@ -1,8 +1,8 @@
package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService;
@ -77,7 +77,7 @@ public class S3ImageServiceTests {
return UUID.randomUUID() + ".svg";
}
private Image seedImage(User owner, ImageCreateSpec spec) {
private Image seedImage(User owner, ImageCreateInfoSpec spec) {
try (final InputStream hal9000 = getHal9000InputStream()) {
return this.imageService.create(
owner,
@ -92,13 +92,12 @@ public class S3ImageServiceTests {
}
private Image seedImage(User owner) {
return this.seedImage(owner, ImageCreateSpec.builder().build());
return this.seedImage(owner, new ImageCreateInfoSpec());
}
private Image makePublic(Image image, User modifier) {
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
.isPublic(true)
.build();
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setPublic(true);
return this.imageService.update(image, modifier, spec);
}
@ -116,7 +115,7 @@ public class S3ImageServiceTests {
assertThat(image.getMimeType(), is("image/svg+xml"));
assertThat(image.getAlt(), is(nullValue()));
assertThat(image.getCaption(), is(nullValue()));
assertThat(image.getIsPublic(), is(false));
assertThat(image.isPublic(), is(false));
assertThat(image.getViewers(), is(empty()));
}
@ -125,6 +124,7 @@ public class S3ImageServiceTests {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(image, owner));
//noinspection DataFlowIssue
final byte[] contentBytes = content.readAllBytes();
assertThat(contentBytes.length, is((int) HAL_LENGTH));
content.close();
@ -135,6 +135,7 @@ public class S3ImageServiceTests {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(image, owner));
//noinspection DataFlowIssue
content.close();
}
@ -144,6 +145,7 @@ public class S3ImageServiceTests {
final Image seedImage = this.seedImage(owner);
final Image publicImage = this.makePublic(seedImage, owner);
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(publicImage, null));
//noinspection DataFlowIssue
content.close();
}
@ -152,11 +154,11 @@ public class S3ImageServiceTests {
final User owner = this.seedUser();
final User viewer = this.seedUser();
Image seedImage = this.seedImage(owner);
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
.viewersToAdd(Set.of(viewer))
.build();
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setViewersToAdd(Set.of(viewer));
final Image imageWithViewer = this.imageService.update(seedImage, owner, spec);
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(imageWithViewer, viewer));
//noinspection DataFlowIssue
content.close();
}
@ -181,9 +183,8 @@ public class S3ImageServiceTests {
public void updateAlt() {
final User owner = this.seedUser();
Image image = this.seedImage(owner);
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
.alt("HAL 9000")
.build();
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setAlt("HAL 9000");
image = this.imageService.update(image, owner, spec);
assertThat(image.getAlt(), is("HAL 9000"));
}
@ -192,9 +193,8 @@ public class S3ImageServiceTests {
public void updateCaption() {
final User owner = this.seedUser();
Image image = this.seedImage(owner);
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
.caption("HAL 9000 from 2001: A Space Odyssey")
.build();
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setCaption("HAL 9000 from 2001: A Space Odyssey");
image = this.imageService.update(image, owner, spec);
assertThat(image.getCaption(), is("HAL 9000 from 2001: A Space Odyssey"));
}
@ -203,17 +203,15 @@ public class S3ImageServiceTests {
public void updateIsPublic() {
final User owner = this.seedUser();
Image image = this.seedImage(owner);
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
.isPublic(true)
.build();
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setPublic(true);
image = this.imageService.update(image, owner, spec);
assertThat(image.getIsPublic(), is(true));
assertThat(image.isPublic(), is(true));
}
private Image addViewer(Image image, User owner, User viewer) {
final ImageUpdateSpec spec0 = ImageUpdateSpec.builder()
.viewersToAdd(Set.of(viewer))
.build();
final ImageUpdateInfoSpec spec0 = new ImageUpdateInfoSpec();
spec0.setViewersToAdd(Set.of(viewer));
return this.imageService.update(image, owner, spec0);
}
@ -234,9 +232,8 @@ public class S3ImageServiceTests {
image = this.addViewer(image, owner, viewer);
assertThat(image.getViewers(), containsUsers(viewer));
final ImageUpdateSpec spec1 = ImageUpdateSpec.builder()
.viewersToRemove(Set.of(viewer))
.build();
final ImageUpdateInfoSpec spec1 = new ImageUpdateInfoSpec();
spec1.setViewersToRemove(Set.of(viewer));
image = this.imageService.update(image, owner, spec1);
assertThat(image.getViewers(), empty());
}
@ -249,9 +246,8 @@ public class S3ImageServiceTests {
image = this.addViewer(image, owner, viewer);
assertThat(image.getViewers(), containsUsers(viewer));
final ImageUpdateSpec spec1 = ImageUpdateSpec.builder()
.clearAllViewers(true)
.build();
final ImageUpdateInfoSpec spec1 = new ImageUpdateInfoSpec();
spec1.setClearAllViewers(true);
image = this.imageService.update(image, owner, spec1);
assertThat(image.getViewers(), empty());
}

View File

@ -1,75 +0,0 @@
package app.mealsmadeeasy.api.job;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler;
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.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.concurrent.TimeUnit;
import static org.awaitility.Awaitility.await;
@SpringBootTest
@ExtendWith(IntegrationTestsExtension.class)
public class JobServiceIntegrationTests {
@Component
private static final class TestJobHandler implements JobHandler<Object> {
@Override
public Class<Object> getPayloadType() {
return Object.class;
}
@Override
public String getJobKey() {
return "TEST_JOB_TYPE";
}
@Override
public void handle(Job job, Object payload) {
// no-op
}
}
@TestConfiguration
public static class JobServiceIntegrationTestsConfiguration {
@Bean
public JobHandler<Object> testJobHandler() {
return new TestJobHandler();
}
}
// If not mockito, it will try to load a ChatClient
@MockitoBean
private RecipeInferJobHandler recipeInferJobHandler;
@Autowired
private JobService jobService;
@Autowired
private JobRepository jobRepository;
@Test
public void smokeScreen() {
final Job job = this.jobService.create("TEST_JOB_TYPE", null);
await().atMost(1, TimeUnit.SECONDS).until(() -> {
final Job foundJob = this.jobRepository.findById(job.getId()).orElse(null);
if (foundJob == null) {
return false;
} else {
return foundJob.getState().equals(Job.State.DONE);
}
});
}
}

View File

@ -7,7 +7,7 @@ import app.mealsmadeeasy.api.auth.LoginException;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.image.S3ImageServiceTests;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
@ -93,15 +93,14 @@ public class RecipeControllerTests {
}
private Recipe createTestRecipe(User owner, boolean isPublic) {
final RecipeCreateSpec spec = RecipeCreateSpec.builder()
.slug(UUID.randomUUID().toString())
.title("Test Recipe")
.preparationTime(10)
.cookingTime(20)
.totalTime(30)
.rawText("# Hello, World!")
.isPublic(isPublic)
.build();
final RecipeCreateSpec spec = new RecipeCreateSpec();
spec.setSlug(UUID.randomUUID().toString());
spec.setTitle("Test Recipe");
spec.setPreparationTime(10);
spec.setCookingTime(20);
spec.setTotalTime(30);
spec.setRawText("# Hello, World!");
spec.setPublic(isPublic);
return this.recipeService.create(owner, spec);
}
@ -118,7 +117,7 @@ public class RecipeControllerTests {
UUID.randomUUID() + ".svg",
hal9000,
27881L,
ImageCreateSpec.builder().build()
new ImageCreateInfoSpec()
);
} catch (Exception e) {
throw new RuntimeException(e);
@ -147,7 +146,7 @@ public class RecipeControllerTests {
.andExpect(jsonPath("$.recipe.owner.username").value(owner.getUsername()))
.andExpect(jsonPath("$.recipe.starCount").value(0))
.andExpect(jsonPath("$.recipe.viewerCount").value(0))
.andExpect(jsonPath("$.recipe.public").value(true))
.andExpect(jsonPath("$.recipe.isPublic").value(true))
.andExpect(jsonPath("$.recipe.mainImage").value(nullValue()))
.andExpect(jsonPath("$.isStarred").value(nullValue()))
.andExpect(jsonPath("$.isOwner").value(nullValue()));
@ -226,14 +225,13 @@ public class RecipeControllerTests {
}
private String getUpdateBody() throws JsonProcessingException {
final RecipeUpdateSpec spec = RecipeUpdateSpec.builder()
.title("Updated Test Recipe")
.preparationTime(15)
.cookingTime(30)
.totalTime(45)
.rawText("# Hello, Updated World!")
.isPublic(true)
.build();
final RecipeUpdateSpec spec = new RecipeUpdateSpec();
spec.setTitle("Updated Test Recipe");
spec.setPreparationTime(15);
spec.setCookingTime(30);
spec.setTotalTime(45);
spec.setRawText("# Hello, Updated World!");
spec.setIsPublic(true);
return this.objectMapper.writeValueAsString(spec);
}
@ -261,7 +259,7 @@ public class RecipeControllerTests {
.andExpect(jsonPath("$.recipe.owner.username").value(owner.getUsername()))
.andExpect(jsonPath("$.recipe.starCount").value(0))
.andExpect(jsonPath("$.recipe.viewerCount").value(0))
.andExpect(jsonPath("$.recipe.public").value(true))
.andExpect(jsonPath("$.recipe.isPublic").value(true))
.andExpect(jsonPath("$.recipe.mainImage").value(nullValue()))
.andExpect(jsonPath("$.isStarred").value(false))
.andExpect(jsonPath("$.isOwner").value(true));
@ -273,25 +271,21 @@ public class RecipeControllerTests {
final Image hal9000 = this.createHal9000(owner);
final RecipeCreateSpec createSpec = RecipeCreateSpec.builder()
.title("Test Recipe")
.slug(UUID.randomUUID().toString())
.isPublic(false)
.rawText("# Hello, World!")
.mainImage(hal9000)
.build();
final RecipeCreateSpec createSpec = new RecipeCreateSpec();
createSpec.setTitle("Test Recipe");
createSpec.setSlug("test-recipe");
createSpec.setPublic(false);
createSpec.setRawText("# Hello, World!");
createSpec.setMainImage(hal9000);
Recipe recipe = this.recipeService.create(owner, createSpec);
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.builder()
.title("Updated Test Recipe")
.rawText("# Hello, Updated World!")
.mainImage(
RecipeUpdateSpec.MainImageUpdateSpec.builder()
.username(hal9000.getOwner().getUsername())
.filename(hal9000.getUserFilename())
.build()
)
.build();
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec();
updateSpec.setTitle("Updated Test Recipe");
updateSpec.setRawText("# Hello, Updated World!");
final RecipeUpdateSpec.MainImageUpdateSpec mainImageUpdateSpec = new RecipeUpdateSpec.MainImageUpdateSpec();
mainImageUpdateSpec.setUsername(hal9000.getOwner().getUsername());
mainImageUpdateSpec.setFilename(hal9000.getUserFilename());
updateSpec.setMainImage(mainImageUpdateSpec);
final String body = this.objectMapper.writeValueAsString(updateSpec);
final String accessToken = this.getAccessToken(owner);

View File

@ -24,7 +24,8 @@ import java.util.UUID;
import static app.mealsmadeeasy.api.recipe.ContainsRecipeInfoViewsForRecipesMatcher.containsRecipeInfoViewsForRecipes;
import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
@ -57,12 +58,11 @@ public class RecipeServiceTests {
}
private Recipe createTestRecipe(@Nullable User owner, boolean isPublic) {
final RecipeCreateSpec spec = RecipeCreateSpec.builder()
.slug(UUID.randomUUID().toString())
.title("My Recipe")
.rawText("Hello!")
.isPublic(isPublic)
.build();
final RecipeCreateSpec spec = new RecipeCreateSpec();
spec.setSlug(UUID.randomUUID().toString());
spec.setTitle("My Recipe");
spec.setRawText("Hello!");
spec.setPublic(isPublic);
return this.recipeService.create(owner, spec);
}
@ -80,9 +80,7 @@ public class RecipeServiceTests {
@Test
public void createWithoutOwnerThrowsAccessDenied() {
assertThrows(AccessDeniedException.class, () -> this.recipeService.create(
null, RecipeCreateSpec.builder().build()
));
assertThrows(AccessDeniedException.class, () -> this.recipeService.create(null, new RecipeCreateSpec()));
}
@Test
@ -158,9 +156,8 @@ public class RecipeServiceTests {
final User owner = this.seedUser();
final User viewer = this.seedUser();
final Recipe notYetPublicRecipe = this.createTestRecipe(owner);
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(notYetPublicRecipe)
.isPublic(true)
.build();
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec(notYetPublicRecipe);
updateSpec.setIsPublic(true);
final Recipe publicRecipe = this.recipeService.update(
notYetPublicRecipe.getOwner().getUsername(),
notYetPublicRecipe.getSlug(),
@ -287,16 +284,14 @@ public class RecipeServiceTests {
@Test
public void updateRawText() throws RecipeException, ImageException {
final User owner = this.seedUser();
final RecipeCreateSpec createSpec = RecipeCreateSpec.builder()
.slug(UUID.randomUUID().toString())
.title("My Recipe")
.rawText("# A Heading")
.build();
final RecipeCreateSpec createSpec = new RecipeCreateSpec();
createSpec.setSlug("my-recipe");
createSpec.setTitle("My Recipe");
createSpec.setRawText("# A Heading");
Recipe recipe = this.recipeService.create(owner, createSpec);
final String newRawText = "# A Heading\n## A Subheading";
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(recipe)
.rawText(newRawText)
.build();
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec(recipe);
updateSpec.setRawText(newRawText);
recipe = this.recipeService.update(
recipe.getOwner().getUsername(),
recipe.getSlug(),
@ -311,9 +306,8 @@ public class RecipeServiceTests {
final User owner = this.seedUser();
final User notOwner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner);
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(recipe)
.rawText("should fail")
.build();
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec();
updateSpec.setRawText("should fail");
assertThrows(
AccessDeniedException.class,
() -> this.recipeService.update(
@ -341,13 +335,4 @@ public class RecipeServiceTests {
assertThrows(AccessDeniedException.class, () -> this.recipeService.deleteRecipe(toDelete.getId(), notOwner));
}
@Test
public void createDraftReturnsDefaultRecipeDraft() {
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
assertThat(recipeDraft.getCreated(), is(notNullValue()));
assertThat(recipeDraft.getState(), is(RecipeDraft.State.ENTER_DATA));
assertThat(recipeDraft.getOwner(), is(owner));
}
}

View File

@ -1,88 +0,0 @@
package app.mealsmadeeasy.api.recipe.job;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.file.File;
import app.mealsmadeeasy.api.file.FileService;
import app.mealsmadeeasy.api.recipe.RecipeDraft;
import app.mealsmadeeasy.api.recipe.RecipeService;
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.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MinIOContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
@SpringBootTest
@ExtendWith(IntegrationTestsExtension.class)
@Testcontainers
public class RecipeInferJobIntegrationTests {
@Container
private static final MinIOContainer minioContainer = new MinIOContainer("minio/minio:latest");
@DynamicPropertySource
public static void minioProperties(DynamicPropertyRegistry registry) {
registry.add("app.mealsmadeeasy.api.minio.endpoint", minioContainer::getS3URL);
registry.add("app.mealsmadeeasy.api.minio.accessKey", minioContainer::getUserName);
registry.add("app.mealsmadeeasy.api.minio.secretKey", minioContainer::getPassword);
}
@Autowired
private UserService userService;
@Autowired
private RecipeService recipeService;
@Autowired
private FileService fileService;
@Test
public void enqueueJobAndWait() throws UserCreateException, IOException {
final String ownerName = UUID.randomUUID().toString();
final User owner = this.userService.createUser(
ownerName,
ownerName + "@test.com",
"test-pass"
);
final File sourceFile = this.fileService.create(
RecipeInferJobIntegrationTests.class.getResourceAsStream("recipe.jpeg"),
"recipe.jpeg",
127673L,
owner
);
final RecipeDraft draft = this.recipeService.createAiDraft(sourceFile, owner);
await().atMost(60, TimeUnit.SECONDS).until(() -> {
final RecipeDraft foundDraft = this.recipeService.getDraftById(draft.getId());
return foundDraft.getState().equals(RecipeDraft.State.ENTER_DATA);
});
final RecipeDraft draftWithInference = this.recipeService.getDraftById(draft.getId());
assertThat(draftWithInference.getInferences(), is(notNullValue()));
final List<RecipeDraft.RecipeDraftInference> inferences = draftWithInference.getInferences();
assertThat(inferences.size(), is(1));
final RecipeDraft.RecipeDraftInference inference = inferences.getFirst();
assertThat(inference.getTitle(), is(notNullValue()));
assertThat(inference.getRawText(), is(notNullValue()));
assertThat(inference.getInferredAt(), is(notNullValue()));
}
}

View File

@ -56,7 +56,7 @@ public class RecipeStarRepositoryTests {
final RecipeStar starDraft = new RecipeStar();
final RecipeStarId starId = new RecipeStarId();
starId.setRecipeId(recipe.getId());
starId.setOwnerId(owner.getId());
starId.getOwnerId(owner.getId());
starDraft.setId(starId);
this.recipeStarRepository.save(starDraft);

View File

@ -44,12 +44,11 @@ public class RecipeStarServiceTests {
}
private Recipe seedRecipe(User owner) {
final RecipeCreateSpec spec = RecipeCreateSpec.builder()
.slug(UUID.randomUUID().toString())
.title("Test Recipe")
.rawText("My great recipe has five ingredients.")
.isPublic(true)
.build();
final RecipeCreateSpec spec = new RecipeCreateSpec();
spec.setSlug(UUID.randomUUID().toString());
spec.setTitle("Test Recipe");
spec.setRawText("My great recipe has five ingredients.");
spec.setPublic(true);
return this.recipeService.create(owner, spec);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

View File

@ -5,7 +5,6 @@ app.mealsmadeeasy.api.minio.endpoint=http://localhost:9000
app.mealsmadeeasy.api.minio.accessKey=minio-root
app.mealsmadeeasy.api.minio.secretKey=test0123
app.mealsmadeeasy.api.images.bucketName=images
app.mealsmadeeasy.api.files.bucketName=files
# Source - https://stackoverflow.com/questions/3164072/large-objects-may-not-be-used-in-auto-commit-mode
# Posted by Iogui, modified by community. See post 'Timeline' for change history

View File

@ -1,7 +1,7 @@
package app.mealsmadeeasy.api;
import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.recipe.RecipeEmbedding;
import app.mealsmadeeasy.api.recipe.RecipeEmbeddingEntity;
import app.mealsmadeeasy.api.recipe.RecipeRepository;
import app.mealsmadeeasy.api.recipe.RecipeService;
import org.slf4j.Logger;
@ -44,7 +44,7 @@ public class BackfillRecipeEmbeddings implements ApplicationRunner {
final String toEmbed = "<h1>" + recipe.getTitle() + "</h1>" + renderedMarkdown;
final float[] embedding = this.embeddingModel.embed(toEmbed);
final RecipeEmbedding recipeEmbedding = new RecipeEmbedding();
final RecipeEmbeddingEntity recipeEmbedding = new RecipeEmbeddingEntity();
recipeEmbedding.setRecipe(recipe);
recipeEmbedding.setEmbedding(embedding);
recipeEmbedding.setTimestamp(OffsetDateTime.now());

View File

@ -2,7 +2,7 @@ package app.mealsmadeeasy.api;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.recipe.RecipeService;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
@ -82,11 +82,10 @@ public class DevConfiguration {
rawFrontMatter, RecipeFrontMatter.class
);
final ImageCreateSpec imageCreateSpec = ImageCreateSpec.builder()
.alt(frontMatter.mainImage.alt)
.caption(frontMatter.mainImage.caption)
.isPublic(frontMatter.mainImage.isPublic)
.build();
final ImageCreateInfoSpec imageCreateSpec = new ImageCreateInfoSpec();
imageCreateSpec.setAlt(frontMatter.mainImage.alt);
imageCreateSpec.setCaption(frontMatter.mainImage.caption);
imageCreateSpec.setPublic(frontMatter.mainImage.isPublic);
final Path givenPath = Path.of(frontMatter.mainImage.src);
final Path resolvedPath = Path.of("dev-data", "images").resolve(givenPath);
final Image mainImage;
@ -102,13 +101,12 @@ public class DevConfiguration {
logger.info("Created mainImage {} for {}", mainImage, recipePath);
}
final RecipeCreateSpec recipeCreateSpec = RecipeCreateSpec.builder()
.slug(frontMatter.slug)
.title(frontMatter.title)
.rawText(rawRecipeText)
.isPublic(frontMatter.isPublic)
.mainImage(mainImage)
.build();
final RecipeCreateSpec recipeCreateSpec = new RecipeCreateSpec();
recipeCreateSpec.setSlug(frontMatter.slug);
recipeCreateSpec.setTitle(frontMatter.title);
recipeCreateSpec.setRawText(rawRecipeText);
recipeCreateSpec.setPublic(frontMatter.isPublic);
recipeCreateSpec.setMainImage(mainImage);
final Recipe recipe = this.recipeService.create(testUser, recipeCreateSpec);
logger.info("Created recipe {}", recipe);
}

View File

@ -37,7 +37,7 @@ public class AuthServiceImpl implements AuthService {
}
private RefreshToken createRefreshToken(User principal) {
final RefreshToken refreshTokenDraft = new RefreshToken();
final RefreshTokenEntity refreshTokenDraft = new RefreshTokenEntity();
refreshTokenDraft.setToken(UUID.randomUUID());
refreshTokenDraft.setIssued(OffsetDateTime.now());
refreshTokenDraft.setExpiration(OffsetDateTime.now().plusSeconds(this.refreshTokenLifetime));
@ -75,15 +75,15 @@ public class AuthServiceImpl implements AuthService {
throw new LoginException(LoginExceptionReason.NO_REFRESH_TOKEN, "No refresh token provided.");
}
final RefreshToken old = this.refreshTokenRepository.findByToken(refreshToken)
final RefreshTokenEntity old = this.refreshTokenRepository.findByToken(refreshToken)
.orElseThrow(() -> new LoginException(
LoginExceptionReason.INVALID_REFRESH_TOKEN,
"No such refresh token: " + refreshToken
));
if (old.getRevoked() || old.getDeleted()) {
if (old.isRevoked() || old.isDeleted()) {
throw new LoginException(LoginExceptionReason.INVALID_REFRESH_TOKEN, "Invalid refresh token.");
}
if (old.getExpiration().isBefore(OffsetDateTime.now())) {
if (old.getExpires().isBefore(OffsetDateTime.now())) {
throw new LoginException(LoginExceptionReason.EXPIRED_REFRESH_TOKEN, "Refresh token is expired.");
}

View File

@ -1,9 +1,24 @@
package app.mealsmadeeasy.api.auth;
import lombok.Data;
public final class LoginBody {
@Data
public class LoginBody {
private String username;
private String password;
public String getUsername() {
return this.username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return this.password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@ -1,10 +1,8 @@
package app.mealsmadeeasy.api.auth;
import app.mealsmadeeasy.api.security.AuthToken;
import lombok.Getter;
@Getter
public class LoginDetails {
public final class LoginDetails {
private final String username;
private final AuthToken accessToken;
@ -16,4 +14,16 @@ public class LoginDetails {
this.refreshToken = refreshToken;
}
public String getUsername() {
return this.username;
}
public AuthToken getAccessToken() {
return this.accessToken;
}
public RefreshToken getRefreshToken() {
return this.refreshToken;
}
}

View File

@ -1,9 +1,6 @@
package app.mealsmadeeasy.api.auth;
import lombok.Getter;
@Getter
public class LoginException extends Exception {
public final class LoginException extends Exception {
private final LoginExceptionReason reason;
@ -17,4 +14,8 @@ public class LoginException extends Exception {
this.reason = reason;
}
public LoginExceptionReason getReason() {
return this.reason;
}
}

View File

@ -1,8 +1,5 @@
package app.mealsmadeeasy.api.auth;
import lombok.Getter;
@Getter
public class LoginExceptionView {
private final LoginExceptionReason reason;
@ -13,4 +10,12 @@ public class LoginExceptionView {
this.message = message;
}
public LoginExceptionReason getReason() {
return this.reason;
}
public String getMessage() {
return this.message;
}
}

View File

@ -1,11 +1,8 @@
package app.mealsmadeeasy.api.auth;
import lombok.Getter;
import java.time.LocalDateTime;
@Getter
public class LoginView {
public final class LoginView {
private final String username;
private final String accessToken;
@ -17,4 +14,16 @@ public class LoginView {
this.expires = expires;
}
public String getUsername() {
return this.username;
}
public String getAccessToken() {
return this.accessToken;
}
public LocalDateTime getExpires() {
return this.expires;
}
}

View File

@ -1,40 +1,13 @@
package app.mealsmadeeasy.api.auth;
import app.mealsmadeeasy.api.user.User;
import jakarta.persistence.*;
import lombok.Data;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
@Entity
@Table(name = "refresh_token")
@Data
public class RefreshToken {
@Id
@Column(nullable = false)
private UUID token;
@Column(nullable = false)
private OffsetDateTime issued;
@Column(nullable = false)
private OffsetDateTime expiration;
@ManyToOne(optional = false)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@Column(nullable = false)
private Boolean deleted = false;
@Column(nullable = false)
private Boolean revoked = false;
public long getLifetime() {
return ChronoUnit.SECONDS.between(this.issued, this.expiration);
}
public interface RefreshToken {
UUID getToken();
long getLifetime();
OffsetDateTime getExpires();
OffsetDateTime getIssued();
boolean isRevoked();
boolean isDeleted();
}

View File

@ -0,0 +1,92 @@
package app.mealsmadeeasy.api.auth;
import app.mealsmadeeasy.api.user.User;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
@Entity(name = "RefreshToken")
@Table(name = "refresh_token")
public class RefreshTokenEntity implements RefreshToken {
@Id
@Column(nullable = false)
private UUID token;
@Column(nullable = false)
private OffsetDateTime issued;
@Column(nullable = false)
private OffsetDateTime expiration;
@ManyToOne(optional = false)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@Column(nullable = false)
private Boolean deleted = false;
@Column(nullable = false)
private Boolean revoked = false;
@Override
public UUID getToken() {
return this.token;
}
public void setToken(UUID token) {
this.token = token;
}
@Override
public OffsetDateTime getIssued() {
return this.issued;
}
public void setIssued(OffsetDateTime issued) {
this.issued = issued;
}
@Override
public OffsetDateTime getExpires() {
return this.expiration;
}
public void setExpiration(OffsetDateTime expiration) {
this.expiration = expiration;
}
@Override
public boolean isRevoked() {
return this.revoked;
}
public void setRevoked(boolean revoked) {
this.revoked = revoked;
}
public User getOwner() {
return this.owner;
}
public void setOwner(User owner) {
this.owner = owner;
}
@Override
public boolean isDeleted() {
return this.deleted;
}
public void setDeleted(boolean deleted) {
this.deleted = deleted;
}
@Override
public long getLifetime() {
return ChronoUnit.SECONDS.between(this.issued, this.expiration);
}
}

View File

@ -8,9 +8,9 @@ import org.springframework.data.jpa.repository.Query;
import java.util.Optional;
import java.util.UUID;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, UUID> {
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
Optional<RefreshToken> findByToken(UUID token);
Optional<RefreshTokenEntity> findByToken(UUID token);
@Modifying
@Transactional

View File

@ -1,36 +0,0 @@
package app.mealsmadeeasy.api.file;
import app.mealsmadeeasy.api.user.User;
import jakarta.persistence.*;
import lombok.Data;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "file")
@Data
public class File {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(nullable = false, updatable = false)
private UUID id;
@Column(nullable = false, updatable = false)
private OffsetDateTime created;
@Column(nullable = false)
private String userFilename;
@Column(nullable = false, updatable = false)
private String mimeType;
@Column(nullable = false, updatable = false)
private String objectName;
@ManyToOne(optional = false)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
}

View File

@ -1,7 +0,0 @@
package app.mealsmadeeasy.api.file;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface FileRepository extends JpaRepository<File, UUID> {}

View File

@ -1,75 +0,0 @@
package app.mealsmadeeasy.api.file;
import app.mealsmadeeasy.api.s3.S3Manager;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.util.MimeTypeService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.time.OffsetDateTime;
import java.util.UUID;
@Service
public class FileService {
private final FileRepository fileRepository;
private final S3Manager s3Manager;
private final MimeTypeService mimeTypeService;
private final String filesBucket;
public FileService(
FileRepository fileRepository,
S3Manager s3Manager,
MimeTypeService mimeTypeService,
@Value("${app.mealsmadeeasy.api.files.bucketName}") String filesBucket
) {
this.fileRepository = fileRepository;
this.s3Manager = s3Manager;
this.mimeTypeService = mimeTypeService;
this.filesBucket = filesBucket;
}
public File create(
InputStream fileContent,
String userFilename,
long fileSize,
User owner
) throws IOException {
final UUID uuid = UUID.randomUUID();
final String mimeType = this.mimeTypeService.getMimeType(userFilename);
final String filename = uuid + "." + this.mimeTypeService.getExtension(mimeType);
final String objectName = this.s3Manager.store(
this.filesBucket,
filename,
mimeType,
fileContent,
fileSize
);
final var file = new File();
file.setCreated(OffsetDateTime.now());
file.setUserFilename(userFilename);
file.setMimeType(mimeType);
file.setObjectName(objectName);
file.setOwner(owner);
return this.fileRepository.save(file);
}
public InputStream getFileContentById(UUID fileId) throws IOException {
final File file = this.fileRepository.findById(fileId).orElseThrow(() -> new IllegalArgumentException(
"File with id " + fileId + " not found"
));
return this.s3Manager.load(this.filesBucket, file.getObjectName());
}
public File getById(UUID id) {
return this.fileRepository.findById(id).orElseThrow(() -> new IllegalArgumentException(
"File with id " + id + " not found"
));
}
}

View File

@ -1,58 +1,22 @@
package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.user.User;
import jakarta.persistence.*;
import lombok.Data;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "image")
@Data
public class Image {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, updatable = false)
private Integer id;
@Column(nullable = false)
private OffsetDateTime created = OffsetDateTime.now();
private OffsetDateTime modified;
@Column(nullable = false)
private String userFilename;
@Column(nullable = false)
private String mimeType;
private String alt;
private String caption;
@Column(nullable = false)
private String objectName;
private Integer height;
private Integer width;
@ManyToOne(optional = false)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@Column(nullable = false)
private Boolean isPublic = false;
@ManyToMany
@JoinTable(
name = "image_viewer",
joinColumns = @JoinColumn(name = "image_id"),
inverseJoinColumns = @JoinColumn(name = "viewer_id")
)
private Set<User> viewers = new HashSet<>();
public interface Image {
Integer getId();
OffsetDateTime getCreated();
@Nullable OffsetDateTime getModified();
String getUserFilename();
String getMimeType();
@Nullable String getAlt();
@Nullable String getCaption();
User getOwner();
boolean isPublic();
@Nullable Integer getHeight();
@Nullable Integer getWidth();
Set<User> getViewers();
}

View File

@ -1,8 +1,8 @@
package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.image.body.ImageUpdateBody;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.image.body.ImageUpdateInfoBody;
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserService;
@ -34,25 +34,27 @@ public class ImageController {
this.userService = userService;
}
private ImageUpdateSpec getImageUpdateSpec(ImageUpdateBody body) {
final var builder = ImageUpdateSpec.builder()
.alt(body.getAlt())
.caption(body.getCaption())
.isPublic(body.getIsPublic())
.clearAllViewers(body.getClearAllViewers());
private ImageUpdateInfoSpec getImageUpdateSpec(ImageUpdateInfoBody body) {
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setAlt(body.getAlt());
spec.setCaption(body.getCaption());
spec.setPublic(body.getPublic());
if (body.getViewersToAdd() != null) {
builder.viewersToAdd(body.getViewersToAdd().stream()
.map(this.userService::getUser)
.collect(Collectors.toSet())
spec.setViewersToAdd(
body.getViewersToAdd().stream()
.map(this.userService::getUser)
.collect(Collectors.toSet())
);
}
if (body.getViewersToRemove() != null) {
builder.viewersToRemove(body.getViewersToRemove().stream()
.map(this.userService::getUser)
.collect(Collectors.toSet())
spec.setViewersToRemove(
body.getViewersToRemove().stream()
.map(this.userService::getUser)
.collect(Collectors.toSet())
);
}
return builder.build();
spec.setClearAllViewers(body.getClearAllViewers());
return spec;
}
@ExceptionHandler
@ -95,20 +97,19 @@ public class ImageController {
if (principal == null) {
throw new AccessDeniedException("Must be logged in.");
}
final var specBuilder = ImageCreateSpec.builder()
.alt(alt)
.caption(caption)
.isPublic(isPublic);
final ImageCreateInfoSpec createSpec = new ImageCreateInfoSpec();
createSpec.setAlt(alt);
createSpec.setCaption(caption);
createSpec.setPublic(isPublic);
if (viewers != null) {
specBuilder.viewersToAdd(viewers.stream().map(this.userService::getUser).collect(Collectors.toSet()));
createSpec.setViewersToAdd(viewers.stream().map(this.userService::getUser).collect(Collectors.toSet()));
}
final Image saved = this.imageService.create(
principal,
filename,
image.getInputStream(),
image.getSize(),
specBuilder.build()
createSpec
);
return ResponseEntity.status(201).body(this.imageService.toImageView(saved, principal));
}
@ -118,7 +119,7 @@ public class ImageController {
@AuthenticationPrincipal User principal,
@PathVariable String username,
@PathVariable String filename,
@RequestBody ImageUpdateBody body
@RequestBody ImageUpdateInfoBody body
) throws ImageException {
if (principal == null) {
throw new AccessDeniedException("Must be logged in.");

View File

@ -9,15 +9,15 @@ import java.util.Objects;
@Component("imageSecurity")
public class ImageSecurityImpl implements ImageSecurity {
private final ImageRepository imageRepository;
private final S3ImageRepository imageRepository;
public ImageSecurityImpl(ImageRepository imageRepository) {
public ImageSecurityImpl(S3ImageRepository imageRepository) {
this.imageRepository = imageRepository;
}
@Override
public boolean isViewableBy(Image image, @Nullable User viewer) {
if (image.getIsPublic()) {
if (image.isPublic()) {
// public image
return true;
} else if (viewer == null) {
@ -28,7 +28,7 @@ public class ImageSecurityImpl implements ImageSecurity {
return true;
} else {
// check if viewer
final Image withViewers = this.imageRepository.getByIdWithViewers(image.getId());
final S3ImageEntity withViewers = this.imageRepository.getByIdWithViewers(image.getId());
for (final User user : withViewers.getViewers()) {
if (user.getId() != null && user.getId().equals(viewer.getId())) {
return true;

View File

@ -1,7 +1,7 @@
package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.user.User;
import org.jetbrains.annotations.Nullable;
@ -12,17 +12,17 @@ import java.util.List;
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, ImageCreateInfoSpec infoSpec)
throws IOException, ImageException;
Image getById(Integer id, @Nullable User viewer) throws ImageException;
Image getById(long id, @Nullable User viewer) throws ImageException;
Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException;
Image getByUsernameAndFilename(String username, String filename, User viewer) throws ImageException;
InputStream getImageContent(Image image, @Nullable User viewer) throws IOException;
List<Image> getImagesOwnedBy(User user);
Image update(Image image, User modifier, ImageUpdateSpec spec);
Image update(Image image, User modifier, ImageUpdateInfoSpec spec);
void deleteImage(Image image, User modifier) throws IOException;

View File

@ -0,0 +1,182 @@
package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.user.User;
import jakarta.persistence.*;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.Set;
@Entity(name = "Image")
@Table(name = "image")
public class S3ImageEntity implements Image {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, updatable = false)
private Integer id;
@Column(nullable = false)
private OffsetDateTime created = OffsetDateTime.now();
private OffsetDateTime modified;
@Column(nullable = false)
private String userFilename;
@Column(nullable = false)
private String mimeType;
private String alt;
private String caption;
@Column(nullable = false)
private String objectName;
private Integer height;
private Integer width;
@ManyToOne(optional = false)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@Column(nullable = false)
private Boolean isPublic = false;
@ManyToMany
@JoinTable(
name = "image_viewer",
joinColumns = @JoinColumn(name = "image_id"),
inverseJoinColumns = @JoinColumn(name = "viewer_id")
)
private Set<User> viewers = new HashSet<>();
@Override
public Integer getId() {
return this.id;
}
public void setId(Integer id) {
this.id = id;
}
@Override
public OffsetDateTime getCreated() {
return this.created;
}
public void setCreated(OffsetDateTime created) {
this.created = created;
}
@Override
public @Nullable OffsetDateTime getModified() {
return this.modified;
}
public void setModified(OffsetDateTime modified) {
this.modified = modified;
}
@Override
public String getUserFilename() {
return this.userFilename;
}
public void setUserFilename(String userFilename) {
this.userFilename = userFilename;
}
@Override
public String getMimeType() {
return this.mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
@Override
public @Nullable String getAlt() {
return this.alt;
}
public void setAlt(String alt) {
this.alt = alt;
}
@Override
public @Nullable String getCaption() {
return this.caption;
}
public void setCaption(String caption) {
this.caption = caption;
}
public String getObjectName() {
return this.objectName;
}
public void setObjectName(String objectName) {
this.objectName = objectName;
}
@Override
public @Nullable Integer getHeight() {
return this.height;
}
public void setHeight(Integer height) {
this.height = height;
}
@Override
public @Nullable Integer getWidth() {
return this.width;
}
public void setWidth(Integer width) {
this.width = width;
}
@Override
public User getOwner() {
return this.owner;
}
public void setOwner(User owner) {
this.owner = owner;
}
@Override
public boolean isPublic() {
return this.isPublic;
}
public void setPublic(Boolean aPublic) {
isPublic = aPublic;
}
@Override
public Set<User> getViewers() {
return Set.copyOf(this.viewers);
}
public Set<User> getViewerEntities() {
return this.viewers;
}
public void setViewers(Set<User> viewers) {
this.viewers = viewers;
}
@Override
public String toString() {
return "S3ImageEntity(" + this.id + ", " + this.userFilename + ", " + this.objectName + ")";
}
}

View File

@ -8,16 +8,16 @@ import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
public interface ImageRepository extends JpaRepository<Image, Integer> {
public interface S3ImageRepository extends JpaRepository<S3ImageEntity, Long> {
@Query("SELECT image FROM Image image WHERE image.id = ?1")
@EntityGraph(attributePaths = { "viewers" })
Image getByIdWithViewers(Integer id);
S3ImageEntity getByIdWithViewers(long id);
List<Image> findAllByOwner(User owner);
Optional<Image> findByOwnerAndUserFilename(User owner, String filename);
List<S3ImageEntity> findAllByOwner(User owner);
Optional<S3ImageEntity> findByOwnerAndUserFilename(User owner, String filename);
@Query("SELECT image from Image image WHERE image.owner.username = ?1 AND image.userFilename = ?2")
Optional<Image> findByOwnerUsernameAndFilename(String username, String filename);
Optional<S3ImageEntity> findByOwnerUsernameAndFilename(String username, String filename);
}

View File

@ -1,11 +1,10 @@
package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.s3.S3Manager;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.util.MimeTypeService;
import org.jetbrains.annotations.Nullable;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PostAuthorize;
@ -20,31 +19,66 @@ import java.io.IOException;
import java.io.InputStream;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class S3ImageService implements ImageService {
private static final Pattern extensionPattern = Pattern.compile(".+\\.(.+)$");
private static final String IMAGE_JPEG = "image/jpeg";
private static final String IMAGE_PNG = "image/png";
private static final String IMAGE_SVG = "image/svg+xml";
private static final String IMAGE_WEBP = "image/webp";
private final S3Manager s3Manager;
private final ImageRepository imageRepository;
private final S3ImageRepository imageRepository;
private final String imageBucketName;
private final String baseUrl;
private final MimeTypeService mimeTypeService;
public S3ImageService(
S3Manager s3Manager,
ImageRepository imageRepository,
S3ImageRepository imageRepository,
@Value("${app.mealsmadeeasy.api.images.bucketName}") String imageBucketName,
@Value("${app.mealsmadeeasy.api.baseUrl}") String baseUrl,
MimeTypeService mimeTypeService
@Value("${app.mealsmadeeasy.api.baseUrl}") String baseUrl
) {
this.s3Manager = s3Manager;
this.imageRepository = imageRepository;
this.imageBucketName = imageBucketName;
this.baseUrl = baseUrl;
this.mimeTypeService = mimeTypeService;
}
private boolean transferFromCreateSpec(Image entity, ImageCreateSpec spec) {
private String getMimeType(String userFilename) {
final Matcher m = extensionPattern.matcher(userFilename);
if (m.matches()) {
final String extension = m.group(1);
return switch (extension) {
case "jpg", "jpeg" -> IMAGE_JPEG;
case "png" -> IMAGE_PNG;
case "svg" -> IMAGE_SVG;
case "webp" -> IMAGE_WEBP;
default -> throw new IllegalArgumentException("Cannot determine mime type for extension: " + extension);
};
} else {
throw new IllegalArgumentException("Cannot determine mime type for filename: " + userFilename);
}
}
private String getExtension(String mimeType) throws ImageException {
return switch (mimeType) {
case IMAGE_JPEG -> "jpg";
case IMAGE_PNG -> "png";
case IMAGE_SVG -> "svg";
case IMAGE_WEBP -> "webp";
default -> throw new ImageException(
ImageException.Type.UNSUPPORTED_IMAGE_TYPE,
"Unsupported mime type: " + mimeType
);
};
}
private boolean transferFromSpec(S3ImageEntity entity, ImageCreateInfoSpec spec) {
boolean didTransfer = false;
if (spec.getAlt() != null) {
entity.setAlt(spec.getAlt());
@ -54,55 +88,22 @@ public class S3ImageService implements ImageService {
entity.setCaption(spec.getCaption());
didTransfer = true;
}
if (spec.getIsPublic() != null) {
entity.setIsPublic(spec.getIsPublic());
if (spec.getPublic() != null) {
entity.setPublic(spec.getPublic());
didTransfer = true;
}
final @Nullable Set<User> viewersToAdd = spec.getViewersToAdd();
if (viewersToAdd != null) {
final Set<User> viewers = new HashSet<>(entity.getViewers());
viewers.addAll(spec.getViewersToAdd());
final Set<User> viewers = new HashSet<>(entity.getViewerEntities());
for (final User viewerToAdd : spec.getViewersToAdd()) {
viewers.add((User) viewerToAdd);
}
entity.setViewers(viewers);
didTransfer = true;
}
return didTransfer;
}
private boolean transferFromUpdateSpec(Image entity, ImageUpdateSpec spec) {
boolean didTransfer = false;
if (spec.getAlt() != null) {
entity.setAlt(spec.getAlt());
didTransfer = true;
}
if (spec.getCaption() != null) {
entity.setCaption(spec.getCaption());
didTransfer = true;
}
if (spec.getIsPublic() != null) {
entity.setIsPublic(spec.getIsPublic());
didTransfer = true;
}
final @Nullable Set<User> viewersToAdd = spec.getViewersToAdd();
if (viewersToAdd != null) {
final Set<User> viewers = new HashSet<>(entity.getViewers());
viewers.addAll(spec.getViewersToAdd());
entity.setViewers(viewers);
didTransfer = true;
}
final @Nullable Set<User> viewersToRemove = spec.getViewersToRemove();
if (viewersToRemove != null) {
final Set<User> viewers = new HashSet<>(entity.getViewers());
viewers.removeAll(spec.getViewersToRemove());
entity.setViewers(viewers);
didTransfer = true;
}
if (spec.getClearAllViewers() != null && spec.getClearAllViewers()) {
entity.setViewers(new HashSet<>());
didTransfer = true;
}
return didTransfer;
}
/**
* @apiNote Consumes and closes the {@link java.io.InputStream}.
*
@ -121,11 +122,11 @@ public class S3ImageService implements ImageService {
String userFilename,
InputStream inputStream,
long objectSize,
ImageCreateSpec createSpec
ImageCreateInfoSpec createSpec
) throws IOException, ImageException {
final String mimeType = this.mimeTypeService.getMimeType(userFilename);
final String mimeType = this.getMimeType(userFilename);
final String uuid = UUID.randomUUID().toString();
final String extension = this.mimeTypeService.getExtension(mimeType);
final String extension = this.getExtension(mimeType);
final String filename = uuid + "." + extension;
final var baos = new ByteArrayOutputStream();
@ -151,20 +152,20 @@ public class S3ImageService implements ImageService {
toStore.close();
inputStream.close();
final Image draft = new Image();
draft.setOwner(owner);
final S3ImageEntity draft = new S3ImageEntity();
draft.setOwner((User) owner);
draft.setUserFilename(userFilename);
draft.setMimeType(mimeType);
draft.setObjectName(objectName);
draft.setHeight(height);
draft.setWidth(width);
this.transferFromCreateSpec(draft, createSpec);
this.transferFromSpec(draft, createSpec);
return this.imageRepository.save(draft);
}
@Override
@PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)")
public Image getById(Integer id, @Nullable User viewer) throws ImageException {
public Image getById(long id, @Nullable User viewer) throws ImageException {
return this.imageRepository.findById(id).orElseThrow(() -> new ImageException(
ImageException.Type.INVALID_ID, "No Image with id: " + id
));
@ -194,7 +195,7 @@ public class S3ImageService implements ImageService {
@Override
@PreAuthorize("@imageSecurity.isViewableBy(#image, #viewer)")
public InputStream getImageContent(Image image, User viewer) throws IOException {
return this.s3Manager.load(this.imageBucketName, ((Image) image).getObjectName());
return this.s3Manager.load(this.imageBucketName, ((S3ImageEntity) image).getObjectName());
}
@Override
@ -204,19 +205,36 @@ public class S3ImageService implements ImageService {
@Override
@PreAuthorize("@imageSecurity.isOwner(#image, #modifier)")
public Image update(final Image image, User modifier, ImageUpdateSpec updateSpec) {
final boolean didUpdate = this.transferFromUpdateSpec(image, updateSpec);
if (didUpdate) {
image.setModified(OffsetDateTime.now());
public Image update(final Image image, User modifier, ImageUpdateInfoSpec updateSpec) {
S3ImageEntity entity = (S3ImageEntity) image;
boolean didUpdate = this.transferFromSpec(entity, updateSpec);
final @Nullable Boolean clearAllViewers = updateSpec.getClearAllViewers();
if (clearAllViewers != null && clearAllViewers) {
entity.setViewers(new HashSet<>());
didUpdate = true;
} else {
final @Nullable Set<User> viewersToRemove = updateSpec.getViewersToRemove();
if (viewersToRemove != null) {
final Set<User> currentViewers = new HashSet<>(entity.getViewerEntities());
for (final User toRemove : updateSpec.getViewersToRemove()) {
currentViewers.remove((User) toRemove);
}
entity.setViewers(currentViewers);
didUpdate = true;
}
}
return this.imageRepository.save(image);
if (didUpdate) {
entity.setModified(OffsetDateTime.now());
}
return this.imageRepository.save(entity);
}
@Override
@PreAuthorize("@imageSecurity.isOwner(#image, #modifier)")
public void deleteImage(Image image, User modifier) throws IOException {
this.imageRepository.delete(image);
this.s3Manager.delete("images", image.getObjectName());
final S3ImageEntity imageEntity = (S3ImageEntity) image;
this.imageRepository.delete(imageEntity);
this.s3Manager.delete("images", imageEntity.getObjectName());
}
private String getImageUrl(Image image) {

View File

@ -1,16 +0,0 @@
package app.mealsmadeeasy.api.image.body;
import lombok.Data;
import org.jetbrains.annotations.Nullable;
import java.util.Set;
@Data
public class ImageUpdateBody {
private @Nullable String alt;
private @Nullable String caption;
private @Nullable Boolean isPublic;
private @Nullable Set<String> viewersToAdd;
private @Nullable Set<String> viewersToRemove;
private @Nullable Boolean clearAllViewers;
}

View File

@ -0,0 +1,64 @@
package app.mealsmadeeasy.api.image.body;
import org.jetbrains.annotations.Nullable;
import java.util.Set;
public class ImageUpdateInfoBody {
private @Nullable String alt;
private @Nullable String caption;
private @Nullable Boolean isPublic;
private @Nullable Set<String> viewersToAdd;
private @Nullable Set<String> viewersToRemove;
private @Nullable Boolean clearAllViewers;
public @Nullable String getAlt() {
return this.alt;
}
public void setAlt(@Nullable String alt) {
this.alt = alt;
}
public @Nullable String getCaption() {
return this.caption;
}
public void setCaption(@Nullable String caption) {
this.caption = caption;
}
public @Nullable Boolean getPublic() {
return this.isPublic;
}
public void setPublic(@Nullable Boolean aPublic) {
isPublic = aPublic;
}
public @Nullable Set<String> getViewersToAdd() {
return this.viewersToAdd;
}
public void setViewersToAdd(@Nullable Set<String> viewersToAdd) {
this.viewersToAdd = viewersToAdd;
}
public @Nullable Set<String> getViewersToRemove() {
return this.viewersToRemove;
}
public void setViewersToRemove(@Nullable Set<String> viewersToRemove) {
this.viewersToRemove = viewersToRemove;
}
public @Nullable Boolean getClearAllViewers() {
return this.clearAllViewers;
}
public void setClearAllViewers(@Nullable Boolean clearAllViewers) {
this.clearAllViewers = clearAllViewers;
}
}

View File

@ -0,0 +1,48 @@
package app.mealsmadeeasy.api.image.spec;
import app.mealsmadeeasy.api.user.User;
import org.jetbrains.annotations.Nullable;
import java.util.HashSet;
import java.util.Set;
public class ImageCreateInfoSpec {
private @Nullable String alt;
private @Nullable String caption;
private @Nullable Boolean isPublic;
private @Nullable Set<User> viewersToAdd = new HashSet<>();
public @Nullable String getAlt() {
return this.alt;
}
public void setAlt(@Nullable String alt) {
this.alt = alt;
}
public @Nullable String getCaption() {
return this.caption;
}
public void setCaption(@Nullable String caption) {
this.caption = caption;
}
public @Nullable Boolean getPublic() {
return this.isPublic;
}
public void setPublic(@Nullable Boolean aPublic) {
isPublic = aPublic;
}
public @Nullable Set<User> getViewersToAdd() {
return this.viewersToAdd;
}
public void setViewersToAdd(@Nullable Set<User> viewersToAdd) {
this.viewersToAdd = viewersToAdd;
}
}

View File

@ -1,17 +0,0 @@
package app.mealsmadeeasy.api.image.spec;
import app.mealsmadeeasy.api.user.User;
import lombok.Builder;
import lombok.Value;
import org.jetbrains.annotations.Nullable;
import java.util.Set;
@Value
@Builder
public class ImageCreateSpec {
@Nullable String alt;
@Nullable String caption;
@Nullable Boolean isPublic;
@Nullable Set<User> viewersToAdd;
}

View File

@ -0,0 +1,29 @@
package app.mealsmadeeasy.api.image.spec;
import app.mealsmadeeasy.api.user.User;
import org.jetbrains.annotations.Nullable;
import java.util.Set;
public class ImageUpdateInfoSpec extends ImageCreateInfoSpec {
private @Nullable Set<User> viewersToRemove;
private @Nullable Boolean clearAllViewers;
public @Nullable Set<User> getViewersToRemove() {
return this.viewersToRemove;
}
public void setViewersToRemove(@Nullable Set<User> viewersToRemove) {
this.viewersToRemove = viewersToRemove;
}
public @Nullable Boolean getClearAllViewers() {
return this.clearAllViewers;
}
public void setClearAllViewers(@Nullable Boolean clearAllViewers) {
this.clearAllViewers = clearAllViewers;
}
}

View File

@ -1,19 +0,0 @@
package app.mealsmadeeasy.api.image.spec;
import app.mealsmadeeasy.api.user.User;
import lombok.Builder;
import lombok.Value;
import org.jetbrains.annotations.Nullable;
import java.util.Set;
@Value
@Builder
public class ImageUpdateSpec {
@Nullable String alt;
@Nullable String caption;
@Nullable Boolean isPublic;
@Nullable Set<User> viewersToAdd;
@Nullable Set<User> viewersToRemove;
@Nullable Boolean clearAllViewers;
}

View File

@ -0,0 +1,6 @@
package app.mealsmadeeasy.api.image.view;
public class ImageExceptionView {
}

View File

@ -2,52 +2,143 @@ package app.mealsmadeeasy.api.image.view;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.user.view.UserInfoView;
import lombok.Builder;
import lombok.Value;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
import java.util.Set;
import java.util.stream.Collectors;
@Value
@Builder
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());
final ImageView view = new ImageView();
view.setUrl(url);
view.setCreated(image.getCreated());
view.setModified(image.getModified());
view.setFilename(image.getUserFilename());
view.setMimeType(image.getMimeType());
view.setAlt(image.getAlt());
view.setCaption(image.getCaption());
view.setOwner(UserInfoView.from(image.getOwner()));
view.setIsPublic(image.isPublic());
view.setHeight(image.getHeight());
view.setWidth(image.getWidth());
if (includeViewers) {
builder.viewers(
image.getViewers().stream()
.map(UserInfoView::from)
.collect(Collectors.toSet())
view.setViewers(image.getViewers().stream()
.map(UserInfoView::from)
.collect(Collectors.toSet())
);
}
return builder.build();
return view;
}
String url;
OffsetDateTime created;
@Nullable OffsetDateTime modified;
String filename;
String mimeType;
@Nullable String alt;
@Nullable String caption;
UserInfoView owner;
boolean isPublic;
@Nullable Integer height;
@Nullable Integer width;
@Nullable Set<UserInfoView> viewers;
private String url;
private OffsetDateTime created;
private @Nullable OffsetDateTime modified;
private String filename;
private String mimeType;
private @Nullable String alt;
private @Nullable String caption;
private UserInfoView owner;
private boolean isPublic;
private @Nullable Integer height;
private @Nullable Integer width;
private @Nullable Set<UserInfoView> viewers;
public String getUrl() {
return this.url;
}
public void setUrl(String url) {
this.url = url;
}
public OffsetDateTime getCreated() {
return this.created;
}
public void setCreated(OffsetDateTime created) {
this.created = created;
}
public @Nullable OffsetDateTime getModified() {
return this.modified;
}
public void setModified(@Nullable OffsetDateTime modified) {
this.modified = modified;
}
public String getFilename() {
return this.filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
public String getMimeType() {
return this.mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public @Nullable String getAlt() {
return this.alt;
}
public void setAlt(@Nullable String alt) {
this.alt = alt;
}
public @Nullable String getCaption() {
return this.caption;
}
public void setCaption(@Nullable String caption) {
this.caption = caption;
}
public UserInfoView getOwner() {
return this.owner;
}
public void setOwner(UserInfoView owner) {
this.owner = owner;
}
public boolean getIsPublic() {
return this.isPublic;
}
public void setIsPublic(boolean isPublic) {
this.isPublic = isPublic;
}
public @Nullable Integer getHeight() {
return this.height;
}
public void setHeight(Integer height) {
this.height = height;
}
public @Nullable Integer getWidth() {
return this.width;
}
public void setWidth(Integer width) {
this.width = width;
}
public @Nullable Set<UserInfoView> getViewers() {
return this.viewers;
}
public void setViewers(@Nullable Set<UserInfoView> viewers) {
this.viewers = viewers;
}
}

View File

@ -1,60 +0,0 @@
package app.mealsmadeeasy.api.job;
import com.fasterxml.jackson.databind.JsonNode;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.Type;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "job")
@Data
public class Job {
public enum State {
QUEUED, RUNNING, DONE, FAILED, DEAD
}
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(nullable = false, updatable = false)
private UUID id;
@Column(nullable = false, updatable = false)
private OffsetDateTime created;
private OffsetDateTime modified;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private State state;
@Column(nullable = false)
private String jobKey;
@Type(JsonBinaryType.class)
@Column(nullable = false, columnDefinition = "jsonb")
private JsonNode payload;
@Column(nullable = false)
private Integer attempts;
@Column(nullable = false)
private Integer maxAttempts;
@Column(nullable = false)
private OffsetDateTime runAfter;
private String lockedBy;
private OffsetDateTime lockedAt;
@Lob
@Column(columnDefinition = "TEXT")
@Basic(fetch = FetchType.LAZY)
private String lastError;
}

View File

@ -0,0 +1,21 @@
package app.mealsmadeeasy.api.job;
import jakarta.persistence.Column;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
public class JobEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(nullable = false, updatable = false)
private Long id;
@Column(nullable = false)
private String key;
@Column(nullable = false, columnDefinition = "jsonb")
private String payload;
}

View File

@ -1,7 +0,0 @@
package app.mealsmadeeasy.api.job;
public interface JobHandler<T> {
Class<T> getPayloadType();
String getJobKey();
void handle(Job job, T payload);
}

View File

@ -1,27 +0,0 @@
package app.mealsmadeeasy.api.job;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.Optional;
import java.util.UUID;
public interface JobRepository extends JpaRepository<Job, UUID> {
@Query(value = """
WITH cte AS (
SELECT id FROM job WHERE state = 'QUEUED' AND run_after <= now() ORDER BY run_after, created
FOR UPDATE SKIP LOCKED LIMIT 1
)
UPDATE job j
SET state = 'RUNNING',
locked_by = ?1,
locked_at = now(),
modified = now()
FROM cte
WHERE j.id = cte.id
RETURNING j.*
""", nativeQuery = true)
Optional<Job> claimNext(String lockedBy);
}

View File

@ -1,119 +0,0 @@
package app.mealsmadeeasy.api.job;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.transaction.Transactional;
import org.jetbrains.annotations.Nullable;
import org.springframework.context.ApplicationContext;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
@Service
public class JobService {
private final ApplicationContext applicationContext;
private final JobRepository jobRepository;
private final ObjectMapper objectMapper;
@SuppressWarnings("rawtypes")
private Map<String, JobHandler> jobHandlers;
public JobService(ApplicationContext applicationContext, JobRepository jobRepository, ObjectMapper objectMapper) {
this.applicationContext = applicationContext;
this.jobRepository = jobRepository;
this.objectMapper = objectMapper;
}
public Job create(String jobType, @Nullable Object payload) {
return this.create(jobType, payload, 10, OffsetDateTime.now());
}
public Job create(String jobType, @Nullable Object payload, int maxAttempts, OffsetDateTime runAfter) {
final var job = new Job();
job.setCreated(OffsetDateTime.now());
job.setState(Job.State.QUEUED);
job.setJobKey(jobType);
job.setPayload(this.objectMapper.convertValue(payload, JsonNode.class));
job.setAttempts(0);
job.setMaxAttempts(maxAttempts);
job.setRunAfter(runAfter);
return this.jobRepository.save(job);
}
@SuppressWarnings("rawtypes")
private void initJobHandlers() {
final Map<String, JobHandler> handlersByBeanName = this.applicationContext.getBeansOfType(JobHandler.class);
this.jobHandlers = new HashMap<>(handlersByBeanName);
for (final var jobHandler : handlersByBeanName.values()) {
this.jobHandlers.put(jobHandler.getJobKey(), jobHandler);
}
}
@SuppressWarnings("unchecked,rawtypes")
@Scheduled(fixedDelay = 200)
@Transactional
public void runOneJob() {
final Optional<Job> nextJob = this.jobRepository.claimNext(Thread.currentThread().getName());
if (nextJob.isPresent()) {
if (this.jobHandlers == null) {
this.initJobHandlers();
}
final Job job = nextJob.get();
final JobHandler jobHandler = this.jobHandlers.get(job.getJobKey());
if (jobHandler == null) {
throw new RuntimeException("There is no registered job handler for " + job.getJobKey());
}
final Object payload = this.objectMapper.convertValue(
job.getPayload(),
jobHandler.getPayloadType()
);
try {
jobHandler.handle(job, payload);
job.setState(Job.State.DONE);
job.setModified(OffsetDateTime.now());
job.setLockedBy(null);
job.setLockedAt(null);
this.jobRepository.save(job);
} catch (Exception e) {
final int attemptCount = job.getAttempts() + 1;
final boolean isDead = attemptCount >= job.getMaxAttempts();
final OffsetDateTime runAfter = isDead
? OffsetDateTime.now()
: OffsetDateTime.now().plusSeconds(getBackoffSeconds(attemptCount));
final String lastError = formatException(e);
job.setState(isDead ? Job.State.DEAD : Job.State.QUEUED);
job.setAttempts(attemptCount);
job.setRunAfter(runAfter);
job.setLastError(lastError);
job.setModified(OffsetDateTime.now());
job.setLockedBy(null);
job.setLockedAt(null);
this.jobRepository.save(job);
}
}
}
private static long getBackoffSeconds(int attemptCount) {
final long base = (long) Math.min(300, Math.pow(2, attemptCount));
final long jitter = ThreadLocalRandom.current().nextLong(0, 5);
return base + jitter;
}
private static String formatException(Exception e) {
final var sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
final String s = sw.toString();
return s.length() <= 8000 ? s : s.substring(0, 8000);
}
}

View File

@ -1,6 +1,7 @@
package app.mealsmadeeasy.api.jwt;
import app.mealsmadeeasy.api.security.AuthToken;
import app.mealsmadeeasy.api.security.SimpleAuthToken;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Serializer;
@ -42,7 +43,7 @@ public final class JwtServiceImpl implements JwtService {
.signWith(this.secretKey)
.json(this.serializer)
.compact();
return new AuthToken(
return new SimpleAuthToken(
token,
this.accessTokenLifetime,
LocalDateTime.ofInstant(expires, ZoneId.systemDefault())

View File

@ -1,6 +1,6 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.S3ImageEntity;
import app.mealsmadeeasy.api.recipe.comment.RecipeComment;
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.user.User;
@ -12,9 +12,9 @@ import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.Set;
@Entity
@Entity(name = "Recipe")
@Data
public class Recipe {
public final class Recipe {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@ -75,10 +75,9 @@ public class Recipe {
@ManyToOne
@JoinColumn(name = "main_image_id")
@Nullable
private Image mainImage;
private S3ImageEntity mainImage;
@OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private RecipeEmbedding embedding;
private RecipeEmbeddingEntity embedding;
}

View File

@ -1,13 +1,12 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody;
import app.mealsmadeeasy.api.recipe.body.RecipeSearchBody;
import app.mealsmadeeasy.api.recipe.body.RecipeUpdateBody;
import app.mealsmadeeasy.api.recipe.comment.RecipeComment;
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentCreateBody;
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentService;
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentView;
import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
@ -95,11 +94,10 @@ public class RecipeController {
@PathVariable String username,
@PathVariable String slug,
@RequestParam(defaultValue = "true") boolean includeRawText,
@RequestBody RecipeUpdateBody updateBody,
@RequestBody RecipeUpdateSpec updateSpec,
@AuthenticationPrincipal User principal
) throws ImageException, RecipeException {
final RecipeUpdateSpec spec = RecipeUpdateSpec.from(updateBody);
final Recipe updated = this.recipeService.update(username, slug, spec, principal);
final Recipe updated = this.recipeService.update(username, slug, updateSpec, principal);
final FullRecipeView view = this.recipeService.toFullRecipeView(updated, includeRawText, principal);
return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, principal));
}
@ -119,9 +117,9 @@ public class RecipeController {
@AuthenticationPrincipal User user
) {
if (recipeSearchBody.getType() == RecipeSearchBody.Type.AI_PROMPT) {
final RecipeAiSearchBody spec = this.objectMapper.convertValue(
final RecipeAiSearchSpec spec = this.objectMapper.convertValue(
recipeSearchBody.getData(),
RecipeAiSearchBody.class
RecipeAiSearchSpec.class
);
final List<RecipeInfoView> results = this.recipeService.aiSearch(spec, user);
return ResponseEntity.ok(Map.of("results", results));

View File

@ -1,63 +0,0 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.user.User;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.Type;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "recipe_draft")
@Data
public class RecipeDraft {
public enum State {
INFER,
ENTER_DATA
}
@Data
public static class RecipeDraftInference {
private OffsetDateTime inferredAt;
private String title;
private String rawText;
}
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(nullable = false, unique = true, updatable = false)
private UUID id;
@Column(nullable = false)
private OffsetDateTime created;
private @Nullable OffsetDateTime modified;
@Column(nullable = false)
private State state;
private @Nullable String slug;
private @Nullable String title;
private @Nullable Integer preparationTime;
private @Nullable Integer cookingTime;
private @Nullable Integer totalTime;
private @Nullable String rawText;
@ManyToOne(optional = false)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@ManyToOne
@JoinColumn(name = "main_image_id")
private @Nullable Image mainImage;
@Type(JsonBinaryType.class)
@Column(columnDefinition = "jsonb")
private @Nullable List<RecipeDraftInference> inferences;
}

View File

@ -1,7 +0,0 @@
package app.mealsmadeeasy.api.recipe;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface RecipeDraftRepository extends JpaRepository<RecipeDraft, UUID> {}

View File

@ -1,33 +0,0 @@
package app.mealsmadeeasy.api.recipe;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.Array;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
@Entity
@Table(name = "recipe_embedding")
@Data
public class RecipeEmbedding {
@Id
private Integer id;
@OneToOne(fetch = FetchType.LAZY, optional = false)
@MapsId
@JoinColumn(name = "recipe_id")
private Recipe recipe;
@JdbcTypeCode(SqlTypes.VECTOR)
@Array(length = 1024)
@Nullable
private float[] embedding;
@Column(nullable = false)
private OffsetDateTime timestamp;
}

View File

@ -0,0 +1,63 @@
package app.mealsmadeeasy.api.recipe;
import jakarta.persistence.*;
import org.hibernate.annotations.Array;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
@Entity
@Table(name = "recipe_embedding")
public class RecipeEmbeddingEntity {
@Id
private Integer id;
@OneToOne(fetch = FetchType.LAZY, optional = false)
@MapsId
@JoinColumn(name = "recipe_id")
private Recipe recipe;
@JdbcTypeCode(SqlTypes.VECTOR)
@Array(length = 1024)
@Nullable
private float[] embedding;
@Column(nullable = false)
private OffsetDateTime timestamp;
public Integer getId() {
return this.id;
}
public void setId(Integer id) {
this.id = id;
}
public Recipe getRecipe() {
return this.recipe;
}
public void setRecipe(Recipe recipe) {
this.recipe = recipe;
}
public float[] getEmbedding() {
return this.embedding;
}
public void setEmbedding(float[] embedding) {
this.embedding = embedding;
}
public OffsetDateTime getTimestamp() {
return this.timestamp;
}
public void setTimestamp(OffsetDateTime timestamp) {
this.timestamp = timestamp;
}
}

View File

@ -10,7 +10,7 @@ import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
public interface RecipeRepository extends JpaRepository<Recipe, Integer> {
public interface RecipeRepository extends JpaRepository<Recipe, Long> {
List<Recipe> findAllByIsPublicIsTrue();

View File

@ -2,82 +2,12 @@ package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.user.User;
import org.jetbrains.annotations.Nullable;
import org.springframework.stereotype.Component;
import java.util.Objects;
@Component
public class RecipeSecurity {
private final RecipeRepository recipeRepository;
public RecipeSecurity(RecipeRepository recipeRepository) {
this.recipeRepository = recipeRepository;
}
public boolean isOwner(Recipe recipe, User user) {
return recipe.getOwner() != null && recipe.getOwner().getId().equals(user.getId());
}
public boolean isOwner(Integer recipeId, User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID,
"No such Recipe with id " + recipeId
));
return this.isOwner(recipe, user);
}
public boolean isOwner(String username, String slug, @Nullable User user) 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.isOwner(recipe, user);
}
public boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException {
if (recipe.getIsPublic()) {
// public recipe
return true;
} else if (user == null) {
// a non-public recipe with no principal
return false;
} else if (Objects.equals(recipe.getOwner().getId(), user.getId())) {
// is owner
return true;
} else {
// check if viewer
final Recipe withViewers = this.recipeRepository.findByIdWithViewers(recipe.getId())
.orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID, "No such Recipe with id: " + recipe.getId()
));
for (final User viewer : withViewers.getViewers()) {
if (viewer.getId() != null && viewer.getId().equals(user.getId())) {
return true;
}
}
}
// non-public recipe and not viewer
return false;
}
public boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(ownerUsername, slug)
.orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
"No such Recipe for username " + ownerUsername + " and slug: " + slug
));
return this.isViewableBy(recipe, user);
}
public boolean isViewableBy(Integer recipeId, @Nullable User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID,
"No such Recipe with id: " + recipeId
));
return this.isViewableBy(recipe, user);
}
public interface RecipeSecurity {
boolean isOwner(Recipe recipe, User user);
boolean isOwner(long recipeId, User user) throws RecipeException;
boolean isOwner(String username, String slug, @Nullable User user) throws RecipeException;
boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException;
boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException;
boolean isViewableBy(long recipeId, @Nullable User user) throws RecipeException;
}

View File

@ -0,0 +1,89 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.user.User;
import org.jetbrains.annotations.Nullable;
import org.springframework.stereotype.Component;
import java.util.Objects;
@Component("recipeSecurity")
public class RecipeSecurityImpl implements RecipeSecurity {
private final RecipeRepository recipeRepository;
public RecipeSecurityImpl(RecipeRepository recipeRepository) {
this.recipeRepository = recipeRepository;
}
@Override
public boolean isOwner(Recipe recipe, User user) {
return recipe.getOwner() != null && recipe.getOwner().getId().equals(user.getId());
}
@Override
public boolean isOwner(long recipeId, User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID,
"No such Recipe with id " + recipeId
));
return this.isOwner(recipe, user);
}
@Override
public boolean isOwner(String username, String slug, @Nullable User user) 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.isOwner(recipe, user);
}
@Override
public boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException {
if (recipe.getIsPublic()) {
// public recipe
return true;
} else if (user == null) {
// a non-public recipe with no principal
return false;
} else if (Objects.equals(recipe.getOwner().getId(), user.getId())) {
// is owner
return true;
} else {
// check if viewer
final Recipe withViewers = this.recipeRepository.findByIdWithViewers(recipe.getId())
.orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID, "No such Recipe with id: " + recipe.getId()
));
for (final User viewer : withViewers.getViewers()) {
if (viewer.getId() != null && viewer.getId().equals(user.getId())) {
return true;
}
}
}
// non-public recipe and not viewer
return false;
}
@Override
public boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(ownerUsername, slug)
.orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
"No such Recipe for username " + ownerUsername + " and slug: " + slug
));
return this.isViewableBy(recipe, user);
}
@Override
public boolean isViewableBy(long recipeId, @Nullable User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID,
"No such Recipe with id: " + recipeId
));
return this.isViewableBy(recipe, user);
}
}

View File

@ -1,366 +1,63 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.file.File;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.job.JobService;
import app.mealsmadeeasy.api.markdown.MarkdownService;
import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody;
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler;
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobPayload;
import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
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 jakarta.transaction.Transactional;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.Nullable;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.data.domain.Pageable;
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.PreAuthorize;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Service
public class RecipeService {
public interface RecipeService {
private final RecipeRepository recipeRepository;
private final RecipeStarRepository recipeStarRepository;
private final ImageService imageService;
private final MarkdownService markdownService;
private final EmbeddingModel embeddingModel;
private final RecipeDraftRepository recipeDraftRepository;
private final JobService jobService;
Recipe create(@Nullable 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;
}
Recipe getById(long id, @Nullable User viewer) throws RecipeException;
Recipe getByIdWithStars(long id, @Nullable User viewer) throws RecipeException;
Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) throws RecipeException;
public Recipe create(@Nullable User owner, RecipeCreateSpec spec) {
if (owner == null) {
throw new AccessDeniedException("Must be logged in.");
}
final Recipe draft = new Recipe();
draft.setCreated(OffsetDateTime.now());
draft.setOwner(owner);
draft.setSlug(spec.getSlug());
draft.setTitle(spec.getTitle());
draft.setRawText(spec.getRawText());
draft.setMainImage(spec.getMainImage());
draft.setIsPublic(spec.isPublic());
return this.recipeRepository.save(draft);
}
private Recipe findRecipeEntity(Integer id) throws RecipeException {
return this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id
));
}
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
public Recipe getById(Integer id, @Nullable User viewer) throws RecipeException {
return this.findRecipeEntity(id);
}
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
public Recipe getByIdWithStars(Integer id, @Nullable User viewer) throws RecipeException {
return this.recipeRepository.findByIdWithStars(id).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID,
"No such Recipe with id: " + id
));
}
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
public Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) throws RecipeException {
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) {
if (entity.getCachedRenderedText() == null) {
entity.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(entity.getRawText()));
entity = this.recipeRepository.save(entity);
}
return entity.getCachedRenderedText();
}
private int getStarCount(Recipe recipe) {
return this.recipeRepository.getStarCount(recipe.getId());
}
private int getViewerCount(long recipeId) {
return this.recipeRepository.getViewerCount(recipeId);
}
@Contract("null, _ -> null")
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(
FullRecipeView getFullViewById(long id, @Nullable User viewer) throws RecipeException;
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);
}
) throws RecipeException;
public Slice<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer) {
return this.recipeRepository.findAllViewableBy(viewer, pageable).map(recipe ->
this.getInfoView(recipe, viewer)
);
}
Slice<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer);
List<Recipe> getByMinimumStars(long minimumStars, @Nullable User viewer);
List<Recipe> getPublicRecipes();
List<Recipe> getRecipesViewableBy(User viewer);
List<Recipe> getRecipesOwnedBy(User owner);
public List<Recipe> getByMinimumStars(long minimumStars, @Nullable User viewer) {
return List.copyOf(
this.recipeRepository.findAllViewableByStarsGreaterThanEqual(minimumStars, viewer)
);
}
List<RecipeInfoView> aiSearch(RecipeAiSearchSpec searchSpec, @Nullable User viewer);
public List<Recipe> getPublicRecipes() {
return List.copyOf(this.recipeRepository.findAllByIsPublicIsTrue());
}
Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
throws RecipeException, ImageException;
public List<Recipe> getRecipesViewableBy(User viewer) {
return List.copyOf(this.recipeRepository.findAllByViewersContaining(viewer));
}
Recipe addViewer(long id, User modifier, User viewer) throws RecipeException;
Recipe removeViewer(long id, User modifier, User viewer) throws RecipeException;
Recipe clearAllViewers(long id, User modifier) throws RecipeException;
public List<Recipe> getRecipesOwnedBy(User owner) {
return List.copyOf(this.recipeRepository.findAllByOwner(owner));
}
void deleteRecipe(long id, User modifier);
public List<RecipeInfoView> aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) {
final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt());
final List<Recipe> results;
if (viewer == null) {
results = this.recipeRepository.searchByEmbeddingAndIsPublic(queryEmbedding, 0.5f);
} else {
results = this.recipeRepository.searchByEmbeddingAndViewableBy(queryEmbedding, 0.5f, viewer.getId());
}
return results.stream()
.map(recipeEntity -> this.getInfoView(recipeEntity, viewer))
.toList();
}
FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer);
RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer);
private void prepareForUpdate(RecipeUpdateSpec spec, Recipe recipe, User modifier) throws ImageException {
boolean didUpdate = false;
if (spec.getTitle() != null) {
recipe.setTitle(spec.getTitle());
didUpdate = true;
}
if (spec.getPreparationTime() != null) {
recipe.setPreparationTime(spec.getPreparationTime());
didUpdate = true;
}
if (spec.getCookingTime() != null) {
recipe.setCookingTime(spec.getCookingTime());
didUpdate = true;
}
if (spec.getTotalTime() != null) {
recipe.setTotalTime(spec.getTotalTime());
didUpdate = true;
}
if (spec.getRawText() != null) {
recipe.setRawText(spec.getRawText());
recipe.setCachedRenderedText(null);
didUpdate = true;
}
if (spec.getIsPublic() != null) {
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
if (spec.getMainImage() != null) {
final Image mainImage = this.imageService.getByUsernameAndFilename(
spec.getMainImage().getUsername(),
spec.getMainImage().getFilename(),
modifier
);
recipe.setMainImage(mainImage);
}
if (didUpdate) {
recipe.setModified(OffsetDateTime.now());
}
}
@PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)")
public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
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);
return this.recipeRepository.save(recipe);
}
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public Recipe addViewer(Integer id, User modifier, User viewer) throws RecipeException {
final Recipe entity = this.recipeRepository.findByIdWithViewers(id).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id
));
final Set<User> viewers = new HashSet<>(entity.getViewers());
viewers.add(viewer);
entity.setViewers(viewers);
return this.recipeRepository.save(entity);
}
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public Recipe removeViewer(Integer id, User modifier, User viewer) throws RecipeException {
final Recipe entity = this.findRecipeEntity(id);
final Set<User> viewers = new HashSet<>(entity.getViewers());
viewers.remove(viewer);
entity.setViewers(viewers);
return this.recipeRepository.save(entity);
}
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public Recipe clearAllViewers(Integer id, User modifier) throws RecipeException {
final Recipe entity = this.findRecipeEntity(id);
entity.setViewers(new HashSet<>());
return this.recipeRepository.save(entity);
}
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public void deleteRecipe(Integer id, User modifier) {
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)")
@Contract("_, _, null -> null")
public @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer) {
if (viewer == null) {
return null;
}
return this.recipeStarRepository.isStarer(username, slug, viewer.getId());
}
@Nullable Boolean isStarer(String username, String slug, @Nullable User viewer);
@PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
@Contract("_, _, null -> null")
public @Nullable Boolean isOwner(String username, String slug, @Nullable User viewer) {
if (viewer == null) {
return null;
}
return viewer.getUsername().equals(username);
}
@Nullable Boolean isOwner(String username, String slug, @Nullable User viewer);
public RecipeDraft createDraft(User owner) {
final var recipeDraft = new RecipeDraft();
recipeDraft.setState(RecipeDraft.State.ENTER_DATA);
recipeDraft.setCreated(OffsetDateTime.now());
recipeDraft.setOwner(owner);
return this.recipeDraftRepository.save(recipeDraft);
}
@Transactional
public RecipeDraft createAiDraft(File sourceFile, User owner) {
final var recipeDraft = new RecipeDraft();
recipeDraft.setState(RecipeDraft.State.INFER);
recipeDraft.setCreated(OffsetDateTime.now());
recipeDraft.setOwner(owner);
final var saved = this.recipeDraftRepository.save(recipeDraft);
this.jobService.create(
RecipeInferJobHandler.JOB_KEY,
new RecipeInferJobPayload(saved.getId(), sourceFile.getId()),
1,
OffsetDateTime.now()
);
return saved;
}
public RecipeDraft getDraftById(UUID id) {
return this.recipeDraftRepository.findById(id).orElseThrow(() -> new RuntimeException(
"RecipeDraft with id " + id + " not found"
));
}
public RecipeDraft saveDraft(RecipeDraft recipeDraft) {
return this.recipeDraftRepository.save(recipeDraft);
}
@ApiStatus.Internal
String getRenderedMarkdown(Recipe entity);
}

View File

@ -0,0 +1,316 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.image.S3ImageEntity;
import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.markdown.MarkdownService;
import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
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 org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.Nullable;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.data.domain.Pageable;
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.PreAuthorize;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
public class RecipeServiceImpl implements RecipeService {
private final RecipeRepository recipeRepository;
private final RecipeStarRepository recipeStarRepository;
private final ImageService imageService;
private final MarkdownService markdownService;
private final EmbeddingModel embeddingModel;
public RecipeServiceImpl(
RecipeRepository recipeRepository,
RecipeStarRepository recipeStarRepository,
ImageService imageService,
MarkdownService markdownService,
EmbeddingModel embeddingModel
) {
this.recipeRepository = recipeRepository;
this.recipeStarRepository = recipeStarRepository;
this.imageService = imageService;
this.markdownService = markdownService;
this.embeddingModel = embeddingModel;
}
@Override
public Recipe create(@Nullable User owner, RecipeCreateSpec spec) {
if (owner == null) {
throw new AccessDeniedException("Must be logged in.");
}
final Recipe draft = new Recipe();
draft.setCreated(OffsetDateTime.now());
draft.setOwner((User) owner);
draft.setSlug(spec.getSlug());
draft.setTitle(spec.getTitle());
draft.setRawText(spec.getRawText());
draft.setMainImage((S3ImageEntity) spec.getMainImage());
draft.setIsPublic(spec.isPublic());
return this.recipeRepository.save(draft);
}
private Recipe findRecipeEntity(long id) throws RecipeException {
return this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id
));
}
@Override
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
public Recipe getById(long id, User viewer) throws RecipeException {
return this.findRecipeEntity(id);
}
@Override
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
public Recipe getByIdWithStars(long id, @Nullable User viewer) throws RecipeException {
return this.recipeRepository.findByIdWithStars(id).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID,
"No such Recipe with id: " + id
));
}
@Override
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
public Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) throws RecipeException {
return this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
"No such Recipe for username " + username + " and slug " + slug
));
}
@Override
@ApiStatus.Internal
public String getRenderedMarkdown(Recipe entity) {
if (entity.getCachedRenderedText() == null) {
entity.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(entity.getRawText()));
entity = this.recipeRepository.save(entity);
}
return entity.getCachedRenderedText();
}
private int getStarCount(Recipe recipe) {
return this.recipeRepository.getStarCount(recipe.getId());
}
private int getViewerCount(long recipeId) {
return this.recipeRepository.getViewerCount(recipeId);
}
@Contract("null, _ -> null")
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)
);
}
@Override
@PreAuthorize("@recipeSecurity.isViewableBy(#id, #viewer)")
public FullRecipeView getFullViewById(long 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);
}
@Override
@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);
}
@Override
public Slice<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer) {
return this.recipeRepository.findAllViewableBy((User) viewer, pageable).map(recipe ->
this.getInfoView(recipe, viewer)
);
}
@Override
public List<Recipe> getByMinimumStars(long minimumStars, User viewer) {
return List.copyOf(
this.recipeRepository.findAllViewableByStarsGreaterThanEqual(minimumStars, (User) viewer)
);
}
@Override
public List<Recipe> getPublicRecipes() {
return List.copyOf(this.recipeRepository.findAllByIsPublicIsTrue());
}
@Override
public List<Recipe> getRecipesViewableBy(User viewer) {
return List.copyOf(this.recipeRepository.findAllByViewersContaining((User) viewer));
}
@Override
public List<Recipe> getRecipesOwnedBy(User owner) {
return List.copyOf(this.recipeRepository.findAllByOwner((User) owner));
}
@Override
public List<RecipeInfoView> aiSearch(RecipeAiSearchSpec searchSpec, @Nullable User viewer) {
final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt());
final List<Recipe> results;
if (viewer == null) {
results = this.recipeRepository.searchByEmbeddingAndIsPublic(queryEmbedding, 0.5f);
} else {
results = this.recipeRepository.searchByEmbeddingAndViewableBy(queryEmbedding, 0.5f, viewer.getId());
}
return results.stream()
.map(recipeEntity -> this.getInfoView(recipeEntity, viewer))
.toList();
}
@Override
@PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)")
public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
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
)
);
recipe.setTitle(spec.getTitle());
recipe.setPreparationTime(spec.getPreparationTime());
recipe.setCookingTime(spec.getCookingTime());
recipe.setTotalTime(spec.getTotalTime());
recipe.setRawText(spec.getRawText());
recipe.setCachedRenderedText(null);
recipe.setIsPublic(spec.getIsPublic());
final S3ImageEntity mainImage;
if (spec.getMainImage() == null) {
mainImage = null;
} else {
mainImage = (S3ImageEntity) this.imageService.getByUsernameAndFilename(
spec.getMainImage().getUsername(),
spec.getMainImage().getFilename(),
modifier
);
}
recipe.setMainImage(mainImage);
recipe.setModified(OffsetDateTime.now());
return this.recipeRepository.save(recipe);
}
@Override
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public Recipe addViewer(long id, User modifier, User viewer) throws RecipeException {
final Recipe entity = this.recipeRepository.findByIdWithViewers(id).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id
));
final Set<User> viewers = new HashSet<>(entity.getViewers());
viewers.add((User) viewer);
entity.setViewers(viewers);
return this.recipeRepository.save(entity);
}
@Override
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public Recipe removeViewer(long id, User modifier, User viewer) throws RecipeException {
final Recipe entity = this.findRecipeEntity(id);
final Set<User> viewers = new HashSet<>(entity.getViewers());
viewers.remove((User) viewer);
entity.setViewers(viewers);
return this.recipeRepository.save(entity);
}
@Override
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public Recipe clearAllViewers(long id, User modifier) throws RecipeException {
final Recipe entity = this.findRecipeEntity(id);
entity.setViewers(new HashSet<>());
return this.recipeRepository.save(entity);
}
@Override
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public void deleteRecipe(long id, User modifier) {
this.recipeRepository.deleteById(id);
}
@Override
public FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer) {
return this.getFullView((Recipe) recipe, includeRawText, viewer);
}
@Override
public RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer) {
return this.getInfoView((Recipe) recipe, viewer);
}
@Override
@PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
@Contract("_, _, null -> null")
public @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer) {
if (viewer == null) {
return null;
}
return this.recipeStarRepository.isStarer(username, slug, viewer.getId());
}
@Override
@PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
@Contract("_, _, null -> null")
public @Nullable Boolean isOwner(String username, String slug, @Nullable User viewer) {
if (viewer == null) {
return null;
}
return viewer.getUsername().equals(username);
}
}

View File

@ -1,8 +0,0 @@
package app.mealsmadeeasy.api.recipe.body;
import lombok.Data;
@Data
public class RecipeAiSearchBody {
private String prompt;
}

View File

@ -1,10 +1,7 @@
package app.mealsmadeeasy.api.recipe.body;
import lombok.Data;
import java.util.Map;
@Data
public class RecipeSearchBody {
public enum Type {
@ -14,4 +11,20 @@ public class RecipeSearchBody {
private Type type;
private Map<String, Object> data;
public Type getType() {
return this.type;
}
public void setType(Type type) {
this.type = type;
}
public Map<String, Object> getData() {
return this.data;
}
public void setData(Map<String, Object> data) {
this.data = data;
}
}

View File

@ -1,23 +0,0 @@
package app.mealsmadeeasy.api.recipe.body;
import lombok.Data;
import org.jetbrains.annotations.Nullable;
@Data
public class RecipeUpdateBody {
@Data
public static class MainImageUpdateBody {
private String username;
private String filename;
}
private @Nullable String title;
private @Nullable Integer preparationTime;
private @Nullable Integer cookingTime;
private @Nullable Integer totalTime;
private @Nullable String rawText;
private @Nullable Boolean isPublic;
private @Nullable MainImageUpdateBody mainImage;
}

View File

@ -8,7 +8,6 @@ import lombok.Data;
import java.time.OffsetDateTime;
@Entity
@Table(name = "recipe_comment")
@Data
public final class RecipeComment {

View File

@ -1,8 +1,15 @@
package app.mealsmadeeasy.api.recipe.comment;
import lombok.Data;
@Data
public class RecipeCommentCreateBody {
private String text;
public String getText() {
return this.text;
}
public void setText(String text) {
this.text = text;
}
}

View File

@ -5,7 +5,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RecipeCommentRepository extends JpaRepository<RecipeComment, Integer> {
public interface RecipeCommentRepository extends JpaRepository<RecipeComment, Long> {
void deleteAllByRecipe(Recipe recipe);
Slice<RecipeComment> findAllByRecipe(Recipe recipe, Pageable pageable);
}

View File

@ -8,9 +8,9 @@ import org.springframework.data.domain.Slice;
public interface RecipeCommentService {
RecipeComment create(String recipeUsername, String recipeSlug, User owner, RecipeCommentCreateBody body)
throws RecipeException;
RecipeComment get(Integer commentId, User viewer) throws RecipeException;
RecipeComment get(long commentId, User viewer) throws RecipeException;
Slice<RecipeCommentView> getComments(String recipeUsername, String recipeSlug, Pageable pageable, User viewer)
throws RecipeException;
RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException;
void delete(Integer commentId, User modifier) throws RecipeException;
RecipeComment update(long commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException;
void delete(long commentId, User modifier) throws RecipeException;
}

View File

@ -56,14 +56,14 @@ public class RecipeCommentServiceImpl implements RecipeCommentService {
}
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject.recipe, #viewer)")
private RecipeComment loadCommentEntity(Integer commentId, User viewer) throws RecipeException {
private RecipeComment loadCommentEntity(long commentId, User viewer) throws RecipeException {
return this.recipeCommentRepository.findById(commentId).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_COMMENT_ID, "No such RecipeComment for id: " + commentId
));
}
@Override
public RecipeComment get(Integer commentId, User viewer) throws RecipeException {
public RecipeComment get(long commentId, User viewer) throws RecipeException {
return this.loadCommentEntity(commentId, viewer);
}
@ -84,21 +84,21 @@ public class RecipeCommentServiceImpl implements RecipeCommentService {
}
@Override
public RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException {
public RecipeComment update(long commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException {
final RecipeComment entity = this.loadCommentEntity(commentId, viewer);
entity.setRawText(spec.getRawText());
return this.recipeCommentRepository.save(entity);
}
@PostAuthorize("@recipeSecurity.isOwner(returnObject.recipe, #modifier)")
private RecipeComment loadForDelete(Integer commentId, User modifier) throws RecipeException {
private RecipeComment loadForDelete(long commentId, User modifier) throws RecipeException {
return this.recipeCommentRepository.findById(commentId).orElseThrow(() -> new RecipeException(
RecipeException.Type.INVALID_COMMENT_ID, "No such RecipeComment for id: " + commentId
));
}
@Override
public void delete(Integer commentId, User modifier) throws RecipeException {
public void delete(long commentId, User modifier) throws RecipeException {
final RecipeComment entityToDelete = this.loadForDelete(commentId, modifier);
this.recipeCommentRepository.delete(entityToDelete);
}

View File

@ -1,10 +1,15 @@
package app.mealsmadeeasy.api.recipe.comment;
import lombok.Builder;
import lombok.Value;
@Value
@Builder
public class RecipeCommentUpdateSpec {
String rawText;
private String rawText;
public String getRawText() {
return this.rawText;
}
public void setRawText(String rawText) {
this.rawText = rawText;
}
}

View File

@ -1,36 +1,88 @@
package app.mealsmadeeasy.api.recipe.comment;
import app.mealsmadeeasy.api.user.view.UserInfoView;
import lombok.Builder;
import lombok.Value;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
@Value
@Builder
public class RecipeCommentView {
public static RecipeCommentView from(RecipeComment comment, boolean includeRawText) {
final var builder = RecipeCommentView.builder()
.id(comment.getId())
.created(comment.getCreated())
.modified(comment.getModified())
.text(comment.getCachedRenderedText())
.owner(UserInfoView.from(comment.getOwner()))
.recipeId(comment.getRecipe().getId());
final RecipeCommentView view = new RecipeCommentView();
view.setId(comment.getId());
view.setCreated(comment.getCreated());
view.setModified(comment.getModified());
view.setText(((RecipeComment) comment).getCachedRenderedText());
if (includeRawText) {
builder.rawText(comment.getRawText());
view.setRawText(comment.getRawText());
}
return builder.build();
view.setOwner(UserInfoView.from(comment.getOwner()));
view.setRecipeId(comment.getRecipe().getId());
return view;
}
Integer id;
OffsetDateTime created;
@Nullable OffsetDateTime modified;
String text;
@Nullable String rawText;
UserInfoView owner;
Integer recipeId;
private Integer id;
private OffsetDateTime created;
private @Nullable OffsetDateTime modified;
private String text;
private @Nullable String rawText;
private UserInfoView owner;
private Integer recipeId;
public Integer getId() {
return this.id;
}
public void setId(Integer id) {
this.id = id;
}
public OffsetDateTime getCreated() {
return this.created;
}
public void setCreated(OffsetDateTime created) {
this.created = created;
}
public @Nullable OffsetDateTime getModified() {
return this.modified;
}
public void setModified(@Nullable OffsetDateTime modified) {
this.modified = modified;
}
public String getText() {
return this.text;
}
public void setText(String text) {
this.text = text;
}
public @Nullable String getRawText() {
return this.rawText;
}
public void setRawText(@Nullable String rawText) {
this.rawText = rawText;
}
public UserInfoView getOwner() {
return this.owner;
}
public void setOwner(UserInfoView owner) {
this.owner = owner;
}
public Integer getRecipeId() {
return this.recipeId;
}
public void setRecipeId(Integer recipeId) {
this.recipeId = recipeId;
}
}

View File

@ -1,95 +0,0 @@
package app.mealsmadeeasy.api.recipe.job;
import app.mealsmadeeasy.api.file.File;
import app.mealsmadeeasy.api.file.FileService;
import app.mealsmadeeasy.api.job.Job;
import app.mealsmadeeasy.api.job.JobHandler;
import app.mealsmadeeasy.api.recipe.RecipeDraft;
import app.mealsmadeeasy.api.recipe.RecipeService;
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.core.io.InputStreamResource;
import org.springframework.stereotype.Component;
import org.springframework.util.MimeType;
import java.io.IOException;
import java.io.InputStream;
import java.time.OffsetDateTime;
import java.util.ArrayList;
@Component
@RequiredArgsConstructor
public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload> {
public static final String JOB_KEY = "RECIPE_INFER_JOB";
private final FileService fileService;
private final RecipeService recipeService;
private final ChatModel chatModel;
@Override
public Class<RecipeInferJobPayload> getPayloadType() {
return RecipeInferJobPayload.class;
}
@Override
public String getJobKey() {
return "RECIPE_INFER_JOB";
}
@Override
public void handle(Job job, RecipeInferJobPayload payload) {
final File sourceFile = this.fileService.getById(payload.fileId());
final InputStream sourceFileContent;
try {
sourceFileContent = this.fileService.getFileContentById(payload.fileId());
} catch (IOException e) {
throw new RuntimeException(e);
}
final Media sourceFileMedia = Media.builder()
.data(new InputStreamResource(sourceFileContent))
.mimeType(MimeType.valueOf(sourceFile.getMimeType()))
.build();
final Message ocrMessage = UserMessage.builder()
.text("Convert the recipe in the image 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();
// get recipe draft
final @Nullable RecipeDraft recipeDraft = this.recipeService.getDraftById(payload.recipeDraftId());
if (recipeDraft == null) {
throw new RuntimeException("Recipe draft not found");
}
// set props on draft from ai output, etc.
recipeDraft.setRawText(fullMarkdownText);
recipeDraft.setState(RecipeDraft.State.ENTER_DATA);
if (recipeDraft.getInferences() == null) {
recipeDraft.setInferences(new ArrayList<>());
}
final RecipeDraft.RecipeDraftInference inference = new RecipeDraft.RecipeDraftInference();
inference.setTitle("TODO: inferred title");
inference.setRawText(fullMarkdownText);
inference.setInferredAt(OffsetDateTime.now());
recipeDraft.getInferences().add(inference);
this.recipeService.saveDraft(recipeDraft);
}
}

View File

@ -1,5 +0,0 @@
package app.mealsmadeeasy.api.recipe.job;
import java.util.UUID;
public record RecipeInferJobPayload(UUID recipeDraftId, UUID fileId) {}

View File

@ -0,0 +1,15 @@
package app.mealsmadeeasy.api.recipe.spec;
public class RecipeAiSearchSpec {
private String prompt;
public String getPrompt() {
return this.prompt;
}
public void setPrompt(String prompt) {
this.prompt = prompt;
}
}

View File

@ -1,19 +1,81 @@
package app.mealsmadeeasy.api.recipe.spec;
import app.mealsmadeeasy.api.image.Image;
import lombok.Builder;
import lombok.Value;
import org.jetbrains.annotations.Nullable;
@Value
@Builder
public class RecipeCreateSpec {
String slug;
String title;
@Nullable Integer preparationTime;
@Nullable Integer cookingTime;
@Nullable Integer totalTime;
String rawText;
boolean isPublic;
@Nullable Image mainImage;
private String slug;
private String title;
private @Nullable Integer preparationTime;
private @Nullable Integer cookingTime;
private @Nullable Integer totalTime;
private String rawText;
private boolean isPublic;
private @Nullable Image mainImage;
public String getSlug() {
return this.slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getTitle() {
return this.title;
}
public void setTitle(String title) {
this.title = title;
}
public @Nullable Integer getPreparationTime() {
return this.preparationTime;
}
public void setPreparationTime(@Nullable Integer preparationTime) {
this.preparationTime = preparationTime;
}
public @Nullable Integer getCookingTime() {
return this.cookingTime;
}
public void setCookingTime(@Nullable Integer cookingTime) {
this.cookingTime = cookingTime;
}
public @Nullable Integer getTotalTime() {
return this.totalTime;
}
public void setTotalTime(@Nullable Integer totalTime) {
this.totalTime = totalTime;
}
public String getRawText() {
return this.rawText;
}
public void setRawText(String rawText) {
this.rawText = rawText;
}
public boolean isPublic() {
return this.isPublic;
}
public void setPublic(boolean isPublic) {
this.isPublic = isPublic;
}
public @Nullable Image getMainImage() {
return this.mainImage;
}
public void setMainImage(@Nullable Image mainImage) {
this.mainImage = mainImage;
}
}

View File

@ -2,73 +2,120 @@ package app.mealsmadeeasy.api.recipe.spec;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.recipe.body.RecipeUpdateBody;
import lombok.Builder;
import lombok.Value;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Nullable;
// For now, we cannot change slug after creation.
// In the future, we may be able to have redirects from
// old slugs to new slugs.
@Value
@Builder
public class RecipeUpdateSpec {
@Value
@Builder
public static class MainImageUpdateSpec {
String username;
String filename;
}
public static RecipeUpdateSpec from(RecipeUpdateBody body) {
final var b = RecipeUpdateSpec.builder()
.title(body.getTitle())
.preparationTime(body.getPreparationTime())
.cookingTime(body.getCookingTime())
.totalTime(body.getTotalTime())
.rawText(body.getRawText())
.isPublic(body.getIsPublic());
final @Nullable RecipeUpdateBody.MainImageUpdateBody mainImage = body.getMainImage();
if (mainImage != null) {
b.mainImage(
MainImageUpdateSpec.builder()
.username(mainImage.getUsername())
.filename(mainImage.getFilename())
.build()
);
private String username;
private String filename;
public String getUsername() {
return this.username;
}
return b.build();
public void setUsername(String username) {
this.username = username;
}
public String getFilename() {
return this.filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
}
// For testing convenience only.
@ApiStatus.Internal
public static RecipeUpdateSpec.RecipeUpdateSpecBuilder fromRecipeToBuilder(Recipe recipe) {
final var b = RecipeUpdateSpec.builder()
.title(recipe.getTitle())
.preparationTime(recipe.getPreparationTime())
.cookingTime(recipe.getCookingTime())
.totalTime(recipe.getTotalTime())
.rawText(recipe.getRawText())
.isPublic(recipe.getIsPublic());
private String title;
private @Nullable Integer preparationTime;
private @Nullable Integer cookingTime;
private @Nullable Integer totalTime;
private String rawText;
private boolean isPublic;
private @Nullable MainImageUpdateSpec mainImage;
public RecipeUpdateSpec() {}
/**
* Convenience constructor for testing purposes.
*
* @param recipe the Recipe to copy from
*/
public RecipeUpdateSpec(Recipe recipe) {
this.title = recipe.getTitle();
this.preparationTime = recipe.getPreparationTime();
this.cookingTime = recipe.getCookingTime();
this.totalTime = recipe.getTotalTime();
this.rawText = recipe.getRawText();
this.isPublic = recipe.getIsPublic();
final @Nullable Image mainImage = recipe.getMainImage();
if (recipe.getMainImage() != null) {
b.mainImage(MainImageUpdateSpec.builder()
.username(mainImage.getOwner().getUsername())
.filename(mainImage.getUserFilename())
.build()
);
if (mainImage != null) {
this.mainImage = new MainImageUpdateSpec();
this.mainImage.setUsername(mainImage.getOwner().getUsername());
this.mainImage.setFilename(mainImage.getUserFilename());
}
return b;
}
String title;
@Nullable Integer preparationTime;
@Nullable Integer cookingTime;
@Nullable Integer totalTime;
String rawText;
Boolean isPublic;
@Nullable MainImageUpdateSpec mainImage;
public @Nullable String getTitle() {
return this.title;
}
public void setTitle(@Nullable String title) {
this.title = title;
}
public @Nullable Integer getPreparationTime() {
return this.preparationTime;
}
public void setPreparationTime(@Nullable Integer preparationTime) {
this.preparationTime = preparationTime;
}
public @Nullable Integer getCookingTime() {
return this.cookingTime;
}
public void setCookingTime(@Nullable Integer cookingTime) {
this.cookingTime = cookingTime;
}
public @Nullable Integer getTotalTime() {
return this.totalTime;
}
public void setTotalTime(@Nullable Integer totalTime) {
this.totalTime = totalTime;
}
public @Nullable String getRawText() {
return this.rawText;
}
public void setRawText(@Nullable String rawText) {
this.rawText = rawText;
}
public boolean getIsPublic() {
return this.isPublic;
}
public void setIsPublic(boolean isPublic) {
this.isPublic = isPublic;
}
public @Nullable MainImageUpdateSpec getMainImage() {
return this.mainImage;
}
public void setMainImage(@Nullable MainImageUpdateSpec mainImage) {
this.mainImage = mainImage;
}
}

View File

@ -8,7 +8,7 @@ import lombok.Data;
import java.time.OffsetDateTime;
@Entity
@Entity(name = "RecipeStar")
@Table(name = "recipe_star")
@Data
public final class RecipeStar {

View File

@ -2,10 +2,10 @@ package app.mealsmadeeasy.api.recipe.star;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.Data;
import java.util.Objects;
@Embeddable
@Data
public class RecipeStarId {
@Column(name = "owner_id", nullable = false)
@ -14,4 +14,39 @@ public class RecipeStarId {
@Column(name = "recipe_id", nullable = false)
private Integer recipeId;
public Integer getOwnerId() {
return this.ownerId;
}
public void getOwnerId(Integer ownerId) {
this.ownerId = ownerId;
}
public Integer getRecipeId() {
return this.recipeId;
}
public void setRecipeId(Integer recipeId) {
this.recipeId = recipeId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o instanceof RecipeStarId other) {
return this.recipeId.equals(other.recipeId) && this.ownerId.equals(other.ownerId);
}
return false;
}
@Override
public int hashCode() {
return Objects.hash(this.recipeId, this.ownerId);
}
@Override
public String toString() {
return "RecipeStarId(" + this.recipeId + ", " + this.ownerId + ")";
}
}

View File

@ -25,7 +25,7 @@ public class RecipeStarServiceImpl implements RecipeStarService {
final RecipeStar draft = new RecipeStar();
final RecipeStarId id = new RecipeStarId();
id.setRecipeId(recipeId);
id.setOwnerId(ownerId);
id.getOwnerId(ownerId);
draft.setId(id);
draft.setTimestamp(OffsetDateTime.now());
return this.recipeStarRepository.save(draft);

View File

@ -4,14 +4,11 @@ import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.user.view.UserInfoView;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Value;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
@Value
@Builder
public class FullRecipeView {
public static FullRecipeView from(
@ -22,46 +19,162 @@ public class FullRecipeView {
int viewerCount,
@Nullable ImageView mainImage
) {
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(renderedText)
.owner(UserInfoView.from(recipe.getOwner()))
.starCount(starCount)
.viewerCount(viewerCount)
.mainImage(mainImage)
.isPublic(recipe.getIsPublic());
final FullRecipeView view = new FullRecipeView();
view.setId(recipe.getId());
view.setCreated(recipe.getCreated());
view.setModified(recipe.getModified());
view.setSlug(recipe.getSlug());
view.setTitle(recipe.getTitle());
view.setPreparationTime(recipe.getPreparationTime());
view.setCookingTime(recipe.getCookingTime());
view.setTotalTime(recipe.getTotalTime());
view.setText(renderedText);
if (includeRawText) {
b.rawText(recipe.getRawText());
view.setRawText(recipe.getRawText());
}
return b.build();
view.setOwner(UserInfoView.from(recipe.getOwner()));
view.setStarCount(starCount);
view.setViewerCount(viewerCount);
view.setMainImage(mainImage);
view.setIsPublic(recipe.getIsPublic());
return view;
}
Integer id;
OffsetDateTime created;
@Nullable OffsetDateTime modified;
String slug;
String title;
@Nullable Integer preparationTime;
@Nullable Integer cookingTime;
@Nullable Integer totalTime;
String text;
@Nullable String rawText;
UserInfoView owner;
int starCount;
int viewerCount;
@Nullable ImageView mainImage;
boolean isPublic;
private long id;
private OffsetDateTime created;
private @Nullable OffsetDateTime modified;
private String slug;
private String title;
private @Nullable Integer preparationTime;
private @Nullable Integer cookingTime;
private @Nullable Integer totalTime;
private String text;
private @Nullable String rawText;
private UserInfoView owner;
private int starCount;
private int viewerCount;
private @Nullable ImageView mainImage;
private boolean isPublic;
@JsonInclude(JsonInclude.Include.NON_NULL)
public long getId() {
return this.id;
}
public void setId(long id) {
this.id = id;
}
public OffsetDateTime getCreated() {
return this.created;
}
public void setCreated(OffsetDateTime created) {
this.created = created;
}
public @Nullable OffsetDateTime getModified() {
return this.modified;
}
public void setModified(@Nullable OffsetDateTime modified) {
this.modified = modified;
}
public String getSlug() {
return this.slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getTitle() {
return this.title;
}
public void setTitle(String title) {
this.title = title;
}
public @Nullable Integer getPreparationTime() {
return this.preparationTime;
}
public void setPreparationTime(@Nullable Integer preparationTime) {
this.preparationTime = preparationTime;
}
public @Nullable Integer getCookingTime() {
return this.cookingTime;
}
public void setCookingTime(@Nullable Integer cookingTime) {
this.cookingTime = cookingTime;
}
public @Nullable Integer getTotalTime() {
return this.totalTime;
}
public void setTotalTime(@Nullable Integer totalTime) {
this.totalTime = totalTime;
}
public @Nullable String getText() {
return this.text;
}
public void setText(String text) {
this.text = text;
}
@JsonInclude(Include.NON_NULL)
public @Nullable String getRawText() {
return this.rawText;
}
public void setRawText(@Nullable String rawText) {
this.rawText = rawText;
}
public UserInfoView getOwner() {
return this.owner;
}
public void setOwner(UserInfoView owner) {
this.owner = owner;
}
public int getStarCount() {
return this.starCount;
}
public void setStarCount(int starCount) {
this.starCount = starCount;
}
public int getViewerCount() {
return this.viewerCount;
}
public void setViewerCount(int viewerCount) {
this.viewerCount = viewerCount;
}
public @Nullable ImageView getMainImage() {
return this.mainImage;
}
public void setMainImage(@Nullable ImageView mainImage) {
this.mainImage = mainImage;
}
public boolean getIsPublic() {
return this.isPublic;
}
public void setIsPublic(boolean isPublic) {
this.isPublic = isPublic;
}
}

View File

@ -1,8 +1,5 @@
package app.mealsmadeeasy.api.recipe.view;
import lombok.Getter;
@Getter
public final class RecipeExceptionView {
private final String type;
@ -13,4 +10,12 @@ public final class RecipeExceptionView {
this.message = message;
}
public String getType() {
return this.type;
}
public String getMessage() {
return this.message;
}
}

View File

@ -3,44 +3,42 @@ package app.mealsmadeeasy.api.recipe.view;
import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.user.view.UserInfoView;
import lombok.Builder;
import lombok.Value;
import lombok.Data;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
@Value
@Builder
@Data
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();
final RecipeInfoView view = new RecipeInfoView();
view.setId(recipe.getId());
view.setCreated(recipe.getCreated());
view.setModified(recipe.getModified());
view.setSlug(recipe.getSlug());
view.setTitle(recipe.getTitle());
view.setPreparationTime(recipe.getPreparationTime());
view.setCookingTime(recipe.getCookingTime());
view.setTotalTime(recipe.getTotalTime());
view.setOwner(UserInfoView.from(recipe.getOwner()));
view.setPublic(recipe.getIsPublic());
view.setStarCount(starCount);
view.setMainImage(mainImage);
return view;
}
Integer id;
OffsetDateTime created;
OffsetDateTime modified;
String slug;
String title;
@Nullable Integer preparationTime;
@Nullable Integer cookingTime;
@Nullable Integer totalTime;
UserInfoView owner;
boolean isPublic;
int starCount;
@Nullable ImageView mainImage;
private Integer id;
private OffsetDateTime created;
private OffsetDateTime modified;
private String slug;
private String title;
private @Nullable Integer preparationTime;
private @Nullable Integer cookingTime;
private @Nullable Integer totalTime;
private UserInfoView owner;
private boolean isPublic;
private int starCount;
private @Nullable ImageView mainImage;
}

View File

@ -1,12 +1,9 @@
package app.mealsmadeeasy.api.security;
import lombok.Value;
import java.time.LocalDateTime;
@Value
public class AuthToken {
String token;
long lifetime;
LocalDateTime expires;
public interface AuthToken {
String getToken();
long getLifetime();
LocalDateTime getExpires();
}

View File

@ -1,16 +1,31 @@
package app.mealsmadeeasy.api.security;
import lombok.Value;
@Value
public class SecurityExceptionView {
public enum Action {
LOGIN, REFRESH
}
int status;
Action action;
String message;
private final int status;
private final Action action;
private final String message;
public SecurityExceptionView(int status, Action action, String message) {
this.status = status;
this.action = action;
this.message = message;
}
public int getStatus() {
return this.status;
}
public Action getAction() {
return this.action;
}
public String getMessage() {
return this.message;
}
}

View File

@ -0,0 +1,32 @@
package app.mealsmadeeasy.api.security;
import java.time.LocalDateTime;
public final class SimpleAuthToken implements AuthToken {
private final String token;
private final long lifetime;
private final LocalDateTime expires;
public SimpleAuthToken(String token, long lifetime, LocalDateTime expires) {
this.token = token;
this.lifetime = lifetime;
this.expires = expires;
}
@Override
public String getToken() {
return this.token;
}
@Override
public long getLifetime() {
return this.lifetime;
}
@Override
public LocalDateTime getExpires() {
return this.expires;
}
}

View File

@ -12,7 +12,7 @@ import java.util.Set;
@Entity(name = "User")
@Table(name = "\"user\"")
@Data
public class User implements UserDetails {
public final class User implements UserDetails {
public static User getDefaultDraft() {
final var user = new User();

View File

@ -4,10 +4,10 @@ import jakarta.persistence.*;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
@Entity
@Entity(name = "UserGrantedAuthority")
@Table(name = "user_granted_authority")
@Data
public class UserGrantedAuthority implements GrantedAuthority {
public final class UserGrantedAuthority implements GrantedAuthority {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@ -2,4 +2,4 @@ package app.mealsmadeeasy.api.user;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserGrantedAuthorityRepository extends JpaRepository<UserGrantedAuthority, Integer> {}
public interface UserGrantedAuthorityRepository extends JpaRepository<UserGrantedAuthority, Long> {}

View File

@ -1,16 +1,33 @@
package app.mealsmadeeasy.api.user.view;
import app.mealsmadeeasy.api.user.User;
import lombok.Value;
@Value
public class UserInfoView {
public static UserInfoView from(User user) {
return new UserInfoView(user.getId(), user.getUsername());
final UserInfoView userInfoView = new UserInfoView();
userInfoView.setId(user.getId());
userInfoView.setUsername(user.getUsername());
return userInfoView;
}
Integer id;
String username;
private Integer id;
private String username;
public Integer getId() {
return this.id;
}
public void setId(Integer id) {
this.id = id;
}
public String getUsername() {
return this.username;
}
public void setUsername(String username) {
this.username = username;
}
}

View File

@ -1,9 +1,21 @@
package app.mealsmadeeasy.api.util;
import lombok.Value;
public final class AccessDeniedView {
private final int statusCode;
private final String message;
public AccessDeniedView(int statusCode, String message) {
this.statusCode = statusCode;
this.message = message;
}
public int getStatusCode() {
return this.statusCode;
}
public String getMessage() {
return this.message;
}
@Value
public class AccessDeniedView {
int statusCode;
String message;
}

View File

@ -1,46 +0,0 @@
package app.mealsmadeeasy.api.util;
import org.springframework.stereotype.Service;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class MimeTypeService {
private static final Pattern extensionPattern = Pattern.compile(".+\\.(.+)$");
private static final String IMAGE_JPEG = "image/jpeg";
private static final String IMAGE_PNG = "image/png";
private static final String IMAGE_SVG = "image/svg+xml";
private static final String IMAGE_WEBP = "image/webp";
public String getMimeType(String userFilename) {
final Matcher m = extensionPattern.matcher(userFilename);
if (m.matches()) {
final String extension = m.group(1);
return switch (extension) {
case "jpg", "jpeg" -> IMAGE_JPEG;
case "png" -> IMAGE_PNG;
case "svg" -> IMAGE_SVG;
case "webp" -> IMAGE_WEBP;
default -> throw new IllegalArgumentException("Cannot determine mime type for extension: " + extension);
};
} else {
throw new IllegalArgumentException("Cannot determine mime type for filename: " + userFilename);
}
}
public String getExtension(String mimeType) {
return switch (mimeType) {
case IMAGE_JPEG -> "jpg";
case IMAGE_PNG -> "png";
case IMAGE_SVG -> "svg";
case IMAGE_WEBP -> "webp";
default -> throw new IllegalArgumentException(
"Cannot determine extension from given mimetype: " + mimeType
);
};
}
}

View File

@ -16,7 +16,6 @@ app.mealsmadeeasy.api.minio.endpoint=http://${MINIO_HOST:localhost}:${MINIO_PORT
app.mealsmadeeasy.api.minio.accessKey=${MINIO_ROOT_USER}
app.mealsmadeeasy.api.minio.secretKey=${MINIO_ROOT_PASSWORD}
app.mealsmadeeasy.api.images.bucketName=images
app.mealsmadeeasy.api.files.bucketName=files
# AI
spring.ai.vectorstore.pgvector.dimensions=1024

View File

@ -1,17 +0,0 @@
CREATE TABLE recipe_draft (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created TIMESTAMPTZ(6) NOT NULL,
modified TIMESTAMPTZ(6),
state INT NOT NULL,
slug VARCHAR(255),
title VARCHAR(255),
preparation_time INT,
cooking_time INT,
total_time INT,
raw_text TEXT,
owner_id INT NOT NULL,
main_image_id INT,
inferences JSONB,
FOREIGN KEY (owner_id) REFERENCES "user",
FOREIGN KEY (main_image_id) REFERENCES image
);

View File

@ -1,14 +0,0 @@
CREATE TABLE job (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created TIMESTAMPTZ NOT NULL,
modified TIMESTAMPTZ,
state VARCHAR(64) NOT NULL,
job_key VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
attempts INT NOT NULL,
max_attempts INT NOT NULL,
run_after TIMESTAMPTZ NOT NULL,
locked_by VARCHAR(255),
locked_at TIMESTAMPTZ,
last_error TEXT
);

View File

@ -1,9 +0,0 @@
CREATE TABLE file (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created TIMESTAMPTZ NOT NULL,
user_filename VARCHAR(255) NOT NULL,
mime_type VARCHAR(255) NOT NULL,
object_name VARCHAR(255) NOT NULL,
owner_id INT NOT NULL,
FOREIGN KEY (owner_id) REFERENCES "user"
);

View File

@ -1,98 +0,0 @@
package app.mealsmadeeasy.api.job;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
import java.util.Map;
import java.util.Optional;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
public class JobServiceTests {
@Mock
private JobRepository jobRepository;
@Mock
private ApplicationContext applicationContext;
@Spy
private ObjectMapper objectMapper;
@InjectMocks
private JobService jobService;
@Test
public void whenRunOneJob_callsClaimNext() {
when(this.jobRepository.claimNext(anyString())).thenReturn(Optional.empty());
this.jobService.runOneJob();
verify(this.jobRepository).claimNext(anyString());
}
@Test
public void findsJobHandlerAndSavesJobInfo() {
final Job testJob = new Job();
testJob.setJobKey("TEST_JOB_KEY");
@SuppressWarnings("unchecked")
final JobHandler<Object> testJobHandler = mock(JobHandler.class);
when(testJobHandler.getPayloadType()).thenReturn(Object.class);
when(testJobHandler.getJobKey()).thenReturn("TEST_JOB_KEY");
when(this.jobRepository.claimNext(anyString())).thenReturn(Optional.of(testJob));
when(this.applicationContext.getBeansOfType(JobHandler.class)).thenReturn(
Map.of("testJobHandler", testJobHandler)
);
this.jobService.runOneJob();
verify(testJobHandler).handle(testJob, null);
verify(this.jobRepository).save(testJob);
assertThat(testJob.getState(), is(Job.State.DONE));
assertThat(testJob.getModified(), is(notNullValue()));
assertThat(testJob.getLockedBy(), is(nullValue()));
assertThat(testJob.getLockedAt(), is(nullValue()));
}
@Test
public void retryJobAfterThrowingHandler() {
final Job testJob = new Job();
testJob.setAttempts(0);
testJob.setMaxAttempts(3);
testJob.setJobKey("TEST_JOB_KEY");
final JobHandler<Object> throwingHandler = mock(JobHandler.class);
when(throwingHandler.getPayloadType()).thenReturn(Object.class);
when(throwingHandler.getJobKey()).thenReturn("TEST_JOB_KEY");
doThrow(new RuntimeException("TO THROW")).when(throwingHandler).handle(testJob, null);
when(this.jobRepository.claimNext(anyString())).thenReturn(Optional.of(testJob));
when(this.applicationContext.getBeansOfType(JobHandler.class)).thenReturn(
Map.of("testJobHandler", throwingHandler)
);
this.jobService.runOneJob();
verify(throwingHandler).handle(testJob, null);
verify(this.jobRepository).save(testJob);
assertThat(testJob.getState(), is(Job.State.QUEUED));
assertThat(testJob.getAttempts(), is(1));
assertThat(testJob.getRunAfter(), is(notNullValue()));
assertThat(testJob.getLastError(), is(notNullValue()));
assertThat(testJob.getModified(), is(notNullValue()));
assertThat(testJob.getLockedBy(), is(nullValue()));
assertThat(testJob.getLockedAt(), is(nullValue()));
}
}