Compare commits
8 Commits
0ad45adac1
...
7f985f3434
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f985f3434 | ||
|
|
012bf743a1 | ||
|
|
fc19361ab6 | ||
|
|
51cae79daa | ||
|
|
70c560f0cb | ||
|
|
14c911c283 | ||
|
|
bea8af4a0e | ||
|
|
7e95c3a867 |
@ -80,6 +80,9 @@ 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'
|
||||
@ -98,6 +101,10 @@ 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'
|
||||
}
|
||||
|
||||
@ -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.ImageUpdateInfoBody;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
|
||||
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.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, ImageCreateInfoSpec spec) {
|
||||
private Image seedImage(User owner, ImageCreateSpec spec) {
|
||||
try {
|
||||
return this.imageService.create(
|
||||
owner,
|
||||
@ -104,7 +104,7 @@ public class ImageControllerTests {
|
||||
}
|
||||
|
||||
private Image seedImage(User owner) {
|
||||
return this.seedImage(owner, new ImageCreateInfoSpec());
|
||||
return this.seedImage(owner, ImageCreateSpec.builder().build());
|
||||
}
|
||||
|
||||
private static String getImageUrl(User owner, Image image) {
|
||||
@ -122,8 +122,9 @@ public class ImageControllerTests {
|
||||
@Test
|
||||
public void getPublicImageNoPrincipal() throws Exception {
|
||||
final User owner = this.seedUser();
|
||||
final ImageCreateInfoSpec spec = new ImageCreateInfoSpec();
|
||||
spec.setPublic(true);
|
||||
final ImageCreateSpec spec = ImageCreateSpec.builder()
|
||||
.isPublic(true)
|
||||
.build();
|
||||
final Image image = this.seedImage(owner, spec);
|
||||
|
||||
// Assert bytes the same and proper mime type
|
||||
@ -161,8 +162,9 @@ public class ImageControllerTests {
|
||||
final Image image = this.seedImage(owner);
|
||||
|
||||
// add viewer
|
||||
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||
spec.setViewersToAdd(Set.of(viewer));
|
||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
||||
.viewersToAdd(Set.of(viewer))
|
||||
.build();
|
||||
this.imageService.update(image, owner, spec);
|
||||
|
||||
this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(viewer));
|
||||
@ -195,8 +197,9 @@ public class ImageControllerTests {
|
||||
final User viewer = this.seedUser();
|
||||
|
||||
// add viewer
|
||||
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||
spec.setViewersToAdd(Set.of(viewer));
|
||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
||||
.viewersToAdd(Set.of(viewer))
|
||||
.build();
|
||||
this.imageService.update(image, owner, spec);
|
||||
|
||||
this.mockMvc.perform(get(getImageUrl(owner, image))).andExpect(status().isForbidden());
|
||||
@ -231,7 +234,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("$.isPublic").value(true))
|
||||
.andExpect(jsonPath("$.public").value(true))
|
||||
.andExpect(jsonPath("$.owner.username").value(owner.getUsername()))
|
||||
.andExpect(jsonPath("$.owner.id").value(owner.getId()))
|
||||
.andExpect(jsonPath("$.viewers").value(empty()));
|
||||
@ -243,7 +246,7 @@ public class ImageControllerTests {
|
||||
final User owner = this.seedUser();
|
||||
final Image image = this.seedImage(owner);
|
||||
final String accessToken = this.getAccessToken(owner);
|
||||
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||
final ImageUpdateBody body = new ImageUpdateBody();
|
||||
body.setAlt("HAL 9000");
|
||||
this.mockMvc.perform(
|
||||
post(getImageUrl(owner, image))
|
||||
@ -261,7 +264,7 @@ public class ImageControllerTests {
|
||||
final User owner = this.seedUser();
|
||||
final Image image = this.seedImage(owner);
|
||||
final String accessToken = this.getAccessToken(owner);
|
||||
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||
final ImageUpdateBody body = new ImageUpdateBody();
|
||||
body.setCaption("HAL 9000 from 2001: A Space Odyssey");
|
||||
this.mockMvc.perform(
|
||||
post(getImageUrl(owner, image))
|
||||
@ -279,8 +282,8 @@ public class ImageControllerTests {
|
||||
final User owner = this.seedUser();
|
||||
final Image image = this.seedImage(owner);
|
||||
final String accessToken = this.getAccessToken(owner);
|
||||
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||
body.setPublic(true);
|
||||
final ImageUpdateBody body = new ImageUpdateBody();
|
||||
body.setIsPublic(true);
|
||||
this.mockMvc.perform(
|
||||
post(getImageUrl(owner, image))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@ -289,7 +292,7 @@ public class ImageControllerTests {
|
||||
)
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.modified").value(notNullValue()))
|
||||
.andExpect(jsonPath("$.isPublic").value(true));
|
||||
.andExpect(jsonPath("$.public").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -299,7 +302,7 @@ public class ImageControllerTests {
|
||||
final Image image = this.seedImage(owner);
|
||||
final String accessToken = this.getAccessToken(owner);
|
||||
|
||||
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||
final ImageUpdateBody body = new ImageUpdateBody();
|
||||
final Set<String> viewerUsernames = Set.of(viewerToAdd.getUsername());
|
||||
body.setViewersToAdd(viewerUsernames);
|
||||
|
||||
@ -321,8 +324,9 @@ public class ImageControllerTests {
|
||||
final User owner = this.seedUser();
|
||||
final User viewer = this.seedUser();
|
||||
final Image image = this.seedImage(owner);
|
||||
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||
spec.setViewersToAdd(Set.of(viewer));
|
||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
||||
.viewersToAdd(Set.of(viewer))
|
||||
.build();
|
||||
this.imageService.update(image, owner, spec);
|
||||
return new OwnerViewerImage(owner, viewer, image);
|
||||
}
|
||||
@ -332,7 +336,7 @@ public class ImageControllerTests {
|
||||
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
|
||||
final String accessToken = this.getAccessToken(ownerViewerImage.owner());
|
||||
|
||||
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||
final ImageUpdateBody body = new ImageUpdateBody();
|
||||
body.setViewersToRemove(Set.of(ownerViewerImage.viewer().getUsername()));
|
||||
|
||||
this.mockMvc.perform(
|
||||
@ -350,7 +354,7 @@ public class ImageControllerTests {
|
||||
public void clearAllViewers() throws Exception {
|
||||
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
|
||||
final String accessToken = this.getAccessToken(ownerViewerImage.owner());
|
||||
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||
final ImageUpdateBody body = new ImageUpdateBody();
|
||||
body.setClearAllViewers(true);
|
||||
this.mockMvc.perform(
|
||||
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
|
||||
@ -367,7 +371,7 @@ public class ImageControllerTests {
|
||||
public void updateInfoByViewerForbidden() throws Exception {
|
||||
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
|
||||
final String accessToken = this.getAccessToken(ownerViewerImage.viewer()); // viewer
|
||||
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||
final ImageUpdateBody body = new ImageUpdateBody();
|
||||
this.mockMvc.perform(
|
||||
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
package app.mealsmadeeasy.api.image;
|
||||
|
||||
import app.mealsmadeeasy.api.IntegrationTestsExtension;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
|
||||
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, ImageCreateInfoSpec spec) {
|
||||
private Image seedImage(User owner, ImageCreateSpec spec) {
|
||||
try (final InputStream hal9000 = getHal9000InputStream()) {
|
||||
return this.imageService.create(
|
||||
owner,
|
||||
@ -92,12 +92,13 @@ public class S3ImageServiceTests {
|
||||
}
|
||||
|
||||
private Image seedImage(User owner) {
|
||||
return this.seedImage(owner, new ImageCreateInfoSpec());
|
||||
return this.seedImage(owner, ImageCreateSpec.builder().build());
|
||||
}
|
||||
|
||||
private Image makePublic(Image image, User modifier) {
|
||||
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||
spec.setPublic(true);
|
||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
||||
.isPublic(true)
|
||||
.build();
|
||||
return this.imageService.update(image, modifier, spec);
|
||||
}
|
||||
|
||||
@ -115,7 +116,7 @@ public class S3ImageServiceTests {
|
||||
assertThat(image.getMimeType(), is("image/svg+xml"));
|
||||
assertThat(image.getAlt(), is(nullValue()));
|
||||
assertThat(image.getCaption(), is(nullValue()));
|
||||
assertThat(image.isPublic(), is(false));
|
||||
assertThat(image.getIsPublic(), is(false));
|
||||
assertThat(image.getViewers(), is(empty()));
|
||||
}
|
||||
|
||||
@ -124,7 +125,6 @@ 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,7 +135,6 @@ 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();
|
||||
}
|
||||
|
||||
@ -145,7 +144,6 @@ 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();
|
||||
}
|
||||
|
||||
@ -154,11 +152,11 @@ public class S3ImageServiceTests {
|
||||
final User owner = this.seedUser();
|
||||
final User viewer = this.seedUser();
|
||||
Image seedImage = this.seedImage(owner);
|
||||
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||
spec.setViewersToAdd(Set.of(viewer));
|
||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
||||
.viewersToAdd(Set.of(viewer))
|
||||
.build();
|
||||
final Image imageWithViewer = this.imageService.update(seedImage, owner, spec);
|
||||
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(imageWithViewer, viewer));
|
||||
//noinspection DataFlowIssue
|
||||
content.close();
|
||||
}
|
||||
|
||||
@ -183,8 +181,9 @@ public class S3ImageServiceTests {
|
||||
public void updateAlt() {
|
||||
final User owner = this.seedUser();
|
||||
Image image = this.seedImage(owner);
|
||||
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||
spec.setAlt("HAL 9000");
|
||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
||||
.alt("HAL 9000")
|
||||
.build();
|
||||
image = this.imageService.update(image, owner, spec);
|
||||
assertThat(image.getAlt(), is("HAL 9000"));
|
||||
}
|
||||
@ -193,8 +192,9 @@ public class S3ImageServiceTests {
|
||||
public void updateCaption() {
|
||||
final User owner = this.seedUser();
|
||||
Image image = this.seedImage(owner);
|
||||
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||
spec.setCaption("HAL 9000 from 2001: A Space Odyssey");
|
||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
||||
.caption("HAL 9000 from 2001: A Space Odyssey")
|
||||
.build();
|
||||
image = this.imageService.update(image, owner, spec);
|
||||
assertThat(image.getCaption(), is("HAL 9000 from 2001: A Space Odyssey"));
|
||||
}
|
||||
@ -203,15 +203,17 @@ public class S3ImageServiceTests {
|
||||
public void updateIsPublic() {
|
||||
final User owner = this.seedUser();
|
||||
Image image = this.seedImage(owner);
|
||||
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||
spec.setPublic(true);
|
||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
||||
.isPublic(true)
|
||||
.build();
|
||||
image = this.imageService.update(image, owner, spec);
|
||||
assertThat(image.isPublic(), is(true));
|
||||
assertThat(image.getIsPublic(), is(true));
|
||||
}
|
||||
|
||||
private Image addViewer(Image image, User owner, User viewer) {
|
||||
final ImageUpdateInfoSpec spec0 = new ImageUpdateInfoSpec();
|
||||
spec0.setViewersToAdd(Set.of(viewer));
|
||||
final ImageUpdateSpec spec0 = ImageUpdateSpec.builder()
|
||||
.viewersToAdd(Set.of(viewer))
|
||||
.build();
|
||||
return this.imageService.update(image, owner, spec0);
|
||||
}
|
||||
|
||||
@ -232,8 +234,9 @@ public class S3ImageServiceTests {
|
||||
image = this.addViewer(image, owner, viewer);
|
||||
assertThat(image.getViewers(), containsUsers(viewer));
|
||||
|
||||
final ImageUpdateInfoSpec spec1 = new ImageUpdateInfoSpec();
|
||||
spec1.setViewersToRemove(Set.of(viewer));
|
||||
final ImageUpdateSpec spec1 = ImageUpdateSpec.builder()
|
||||
.viewersToRemove(Set.of(viewer))
|
||||
.build();
|
||||
image = this.imageService.update(image, owner, spec1);
|
||||
assertThat(image.getViewers(), empty());
|
||||
}
|
||||
@ -246,8 +249,9 @@ public class S3ImageServiceTests {
|
||||
image = this.addViewer(image, owner, viewer);
|
||||
assertThat(image.getViewers(), containsUsers(viewer));
|
||||
|
||||
final ImageUpdateInfoSpec spec1 = new ImageUpdateInfoSpec();
|
||||
spec1.setClearAllViewers(true);
|
||||
final ImageUpdateSpec spec1 = ImageUpdateSpec.builder()
|
||||
.clearAllViewers(true)
|
||||
.build();
|
||||
image = this.imageService.update(image, owner, spec1);
|
||||
assertThat(image.getViewers(), empty());
|
||||
}
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@ -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.ImageCreateInfoSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
||||
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
|
||||
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
|
||||
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
|
||||
@ -93,14 +93,15 @@ public class RecipeControllerTests {
|
||||
}
|
||||
|
||||
private Recipe createTestRecipe(User owner, boolean isPublic) {
|
||||
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);
|
||||
final RecipeCreateSpec spec = RecipeCreateSpec.builder()
|
||||
.slug(UUID.randomUUID().toString())
|
||||
.title("Test Recipe")
|
||||
.preparationTime(10)
|
||||
.cookingTime(20)
|
||||
.totalTime(30)
|
||||
.rawText("# Hello, World!")
|
||||
.isPublic(isPublic)
|
||||
.build();
|
||||
return this.recipeService.create(owner, spec);
|
||||
}
|
||||
|
||||
@ -117,7 +118,7 @@ public class RecipeControllerTests {
|
||||
UUID.randomUUID() + ".svg",
|
||||
hal9000,
|
||||
27881L,
|
||||
new ImageCreateInfoSpec()
|
||||
ImageCreateSpec.builder().build()
|
||||
);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
@ -146,7 +147,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.isPublic").value(true))
|
||||
.andExpect(jsonPath("$.recipe.public").value(true))
|
||||
.andExpect(jsonPath("$.recipe.mainImage").value(nullValue()))
|
||||
.andExpect(jsonPath("$.isStarred").value(nullValue()))
|
||||
.andExpect(jsonPath("$.isOwner").value(nullValue()));
|
||||
@ -225,13 +226,14 @@ public class RecipeControllerTests {
|
||||
}
|
||||
|
||||
private String getUpdateBody() throws JsonProcessingException {
|
||||
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);
|
||||
final RecipeUpdateSpec spec = RecipeUpdateSpec.builder()
|
||||
.title("Updated Test Recipe")
|
||||
.preparationTime(15)
|
||||
.cookingTime(30)
|
||||
.totalTime(45)
|
||||
.rawText("# Hello, Updated World!")
|
||||
.isPublic(true)
|
||||
.build();
|
||||
return this.objectMapper.writeValueAsString(spec);
|
||||
}
|
||||
|
||||
@ -259,7 +261,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.isPublic").value(true))
|
||||
.andExpect(jsonPath("$.recipe.public").value(true))
|
||||
.andExpect(jsonPath("$.recipe.mainImage").value(nullValue()))
|
||||
.andExpect(jsonPath("$.isStarred").value(false))
|
||||
.andExpect(jsonPath("$.isOwner").value(true));
|
||||
@ -271,21 +273,25 @@ public class RecipeControllerTests {
|
||||
|
||||
final Image hal9000 = this.createHal9000(owner);
|
||||
|
||||
final RecipeCreateSpec createSpec = new RecipeCreateSpec();
|
||||
createSpec.setTitle("Test Recipe");
|
||||
createSpec.setSlug("test-recipe");
|
||||
createSpec.setPublic(false);
|
||||
createSpec.setRawText("# Hello, World!");
|
||||
createSpec.setMainImage(hal9000);
|
||||
final RecipeCreateSpec createSpec = RecipeCreateSpec.builder()
|
||||
.title("Test Recipe")
|
||||
.slug(UUID.randomUUID().toString())
|
||||
.isPublic(false)
|
||||
.rawText("# Hello, World!")
|
||||
.mainImage(hal9000)
|
||||
.build();
|
||||
Recipe recipe = this.recipeService.create(owner, createSpec);
|
||||
|
||||
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 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 String body = this.objectMapper.writeValueAsString(updateSpec);
|
||||
|
||||
final String accessToken = this.getAccessToken(owner);
|
||||
|
||||
@ -24,8 +24,7 @@ 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.is;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
@ -58,11 +57,12 @@ public class RecipeServiceTests {
|
||||
}
|
||||
|
||||
private Recipe createTestRecipe(@Nullable User owner, boolean isPublic) {
|
||||
final RecipeCreateSpec spec = new RecipeCreateSpec();
|
||||
spec.setSlug(UUID.randomUUID().toString());
|
||||
spec.setTitle("My Recipe");
|
||||
spec.setRawText("Hello!");
|
||||
spec.setPublic(isPublic);
|
||||
final RecipeCreateSpec spec = RecipeCreateSpec.builder()
|
||||
.slug(UUID.randomUUID().toString())
|
||||
.title("My Recipe")
|
||||
.rawText("Hello!")
|
||||
.isPublic(isPublic)
|
||||
.build();
|
||||
return this.recipeService.create(owner, spec);
|
||||
}
|
||||
|
||||
@ -80,7 +80,9 @@ public class RecipeServiceTests {
|
||||
|
||||
@Test
|
||||
public void createWithoutOwnerThrowsAccessDenied() {
|
||||
assertThrows(AccessDeniedException.class, () -> this.recipeService.create(null, new RecipeCreateSpec()));
|
||||
assertThrows(AccessDeniedException.class, () -> this.recipeService.create(
|
||||
null, RecipeCreateSpec.builder().build()
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -156,8 +158,9 @@ public class RecipeServiceTests {
|
||||
final User owner = this.seedUser();
|
||||
final User viewer = this.seedUser();
|
||||
final Recipe notYetPublicRecipe = this.createTestRecipe(owner);
|
||||
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec(notYetPublicRecipe);
|
||||
updateSpec.setIsPublic(true);
|
||||
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(notYetPublicRecipe)
|
||||
.isPublic(true)
|
||||
.build();
|
||||
final Recipe publicRecipe = this.recipeService.update(
|
||||
notYetPublicRecipe.getOwner().getUsername(),
|
||||
notYetPublicRecipe.getSlug(),
|
||||
@ -284,14 +287,16 @@ public class RecipeServiceTests {
|
||||
@Test
|
||||
public void updateRawText() throws RecipeException, ImageException {
|
||||
final User owner = this.seedUser();
|
||||
final RecipeCreateSpec createSpec = new RecipeCreateSpec();
|
||||
createSpec.setSlug("my-recipe");
|
||||
createSpec.setTitle("My Recipe");
|
||||
createSpec.setRawText("# A Heading");
|
||||
final RecipeCreateSpec createSpec = RecipeCreateSpec.builder()
|
||||
.slug(UUID.randomUUID().toString())
|
||||
.title("My Recipe")
|
||||
.rawText("# A Heading")
|
||||
.build();
|
||||
Recipe recipe = this.recipeService.create(owner, createSpec);
|
||||
final String newRawText = "# A Heading\n## A Subheading";
|
||||
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec(recipe);
|
||||
updateSpec.setRawText(newRawText);
|
||||
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(recipe)
|
||||
.rawText(newRawText)
|
||||
.build();
|
||||
recipe = this.recipeService.update(
|
||||
recipe.getOwner().getUsername(),
|
||||
recipe.getSlug(),
|
||||
@ -306,8 +311,9 @@ public class RecipeServiceTests {
|
||||
final User owner = this.seedUser();
|
||||
final User notOwner = this.seedUser();
|
||||
final Recipe recipe = this.createTestRecipe(owner);
|
||||
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec();
|
||||
updateSpec.setRawText("should fail");
|
||||
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(recipe)
|
||||
.rawText("should fail")
|
||||
.build();
|
||||
assertThrows(
|
||||
AccessDeniedException.class,
|
||||
() -> this.recipeService.update(
|
||||
@ -335,4 +341,13 @@ 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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
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()));
|
||||
}
|
||||
|
||||
}
|
||||
@ -56,7 +56,7 @@ public class RecipeStarRepositoryTests {
|
||||
final RecipeStar starDraft = new RecipeStar();
|
||||
final RecipeStarId starId = new RecipeStarId();
|
||||
starId.setRecipeId(recipe.getId());
|
||||
starId.getOwnerId(owner.getId());
|
||||
starId.setOwnerId(owner.getId());
|
||||
starDraft.setId(starId);
|
||||
this.recipeStarRepository.save(starDraft);
|
||||
|
||||
|
||||
@ -44,11 +44,12 @@ public class RecipeStarServiceTests {
|
||||
}
|
||||
|
||||
private Recipe seedRecipe(User owner) {
|
||||
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);
|
||||
final RecipeCreateSpec spec = RecipeCreateSpec.builder()
|
||||
.slug(UUID.randomUUID().toString())
|
||||
.title("Test Recipe")
|
||||
.rawText("My great recipe has five ingredients.")
|
||||
.isPublic(true)
|
||||
.build();
|
||||
return this.recipeService.create(owner, spec);
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@ -5,6 +5,7 @@ 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
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
package app.mealsmadeeasy.api;
|
||||
|
||||
import app.mealsmadeeasy.api.recipe.Recipe;
|
||||
import app.mealsmadeeasy.api.recipe.RecipeEmbeddingEntity;
|
||||
import app.mealsmadeeasy.api.recipe.RecipeEmbedding;
|
||||
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 RecipeEmbeddingEntity recipeEmbedding = new RecipeEmbeddingEntity();
|
||||
final RecipeEmbedding recipeEmbedding = new RecipeEmbedding();
|
||||
recipeEmbedding.setRecipe(recipe);
|
||||
recipeEmbedding.setEmbedding(embedding);
|
||||
recipeEmbedding.setTimestamp(OffsetDateTime.now());
|
||||
|
||||
@ -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.ImageCreateInfoSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
||||
import app.mealsmadeeasy.api.recipe.Recipe;
|
||||
import app.mealsmadeeasy.api.recipe.RecipeService;
|
||||
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
|
||||
@ -82,10 +82,11 @@ public class DevConfiguration {
|
||||
rawFrontMatter, RecipeFrontMatter.class
|
||||
);
|
||||
|
||||
final ImageCreateInfoSpec imageCreateSpec = new ImageCreateInfoSpec();
|
||||
imageCreateSpec.setAlt(frontMatter.mainImage.alt);
|
||||
imageCreateSpec.setCaption(frontMatter.mainImage.caption);
|
||||
imageCreateSpec.setPublic(frontMatter.mainImage.isPublic);
|
||||
final ImageCreateSpec imageCreateSpec = ImageCreateSpec.builder()
|
||||
.alt(frontMatter.mainImage.alt)
|
||||
.caption(frontMatter.mainImage.caption)
|
||||
.isPublic(frontMatter.mainImage.isPublic)
|
||||
.build();
|
||||
final Path givenPath = Path.of(frontMatter.mainImage.src);
|
||||
final Path resolvedPath = Path.of("dev-data", "images").resolve(givenPath);
|
||||
final Image mainImage;
|
||||
@ -101,12 +102,13 @@ public class DevConfiguration {
|
||||
logger.info("Created mainImage {} for {}", mainImage, recipePath);
|
||||
}
|
||||
|
||||
final RecipeCreateSpec recipeCreateSpec = new RecipeCreateSpec();
|
||||
recipeCreateSpec.setSlug(frontMatter.slug);
|
||||
recipeCreateSpec.setTitle(frontMatter.title);
|
||||
recipeCreateSpec.setRawText(rawRecipeText);
|
||||
recipeCreateSpec.setPublic(frontMatter.isPublic);
|
||||
recipeCreateSpec.setMainImage(mainImage);
|
||||
final RecipeCreateSpec recipeCreateSpec = RecipeCreateSpec.builder()
|
||||
.slug(frontMatter.slug)
|
||||
.title(frontMatter.title)
|
||||
.rawText(rawRecipeText)
|
||||
.isPublic(frontMatter.isPublic)
|
||||
.mainImage(mainImage)
|
||||
.build();
|
||||
final Recipe recipe = this.recipeService.create(testUser, recipeCreateSpec);
|
||||
logger.info("Created recipe {}", recipe);
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
}
|
||||
|
||||
private RefreshToken createRefreshToken(User principal) {
|
||||
final RefreshTokenEntity refreshTokenDraft = new RefreshTokenEntity();
|
||||
final RefreshToken refreshTokenDraft = new RefreshToken();
|
||||
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 RefreshTokenEntity old = this.refreshTokenRepository.findByToken(refreshToken)
|
||||
final RefreshToken old = this.refreshTokenRepository.findByToken(refreshToken)
|
||||
.orElseThrow(() -> new LoginException(
|
||||
LoginExceptionReason.INVALID_REFRESH_TOKEN,
|
||||
"No such refresh token: " + refreshToken
|
||||
));
|
||||
if (old.isRevoked() || old.isDeleted()) {
|
||||
if (old.getRevoked() || old.getDeleted()) {
|
||||
throw new LoginException(LoginExceptionReason.INVALID_REFRESH_TOKEN, "Invalid refresh token.");
|
||||
}
|
||||
if (old.getExpires().isBefore(OffsetDateTime.now())) {
|
||||
if (old.getExpiration().isBefore(OffsetDateTime.now())) {
|
||||
throw new LoginException(LoginExceptionReason.EXPIRED_REFRESH_TOKEN, "Refresh token is expired.");
|
||||
}
|
||||
|
||||
|
||||
@ -1,24 +1,9 @@
|
||||
package app.mealsmadeeasy.api.auth;
|
||||
|
||||
public final class LoginBody {
|
||||
import lombok.Data;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
package app.mealsmadeeasy.api.auth;
|
||||
|
||||
import app.mealsmadeeasy.api.security.AuthToken;
|
||||
import lombok.Getter;
|
||||
|
||||
public final class LoginDetails {
|
||||
@Getter
|
||||
public class LoginDetails {
|
||||
|
||||
private final String username;
|
||||
private final AuthToken accessToken;
|
||||
@ -14,16 +16,4 @@ public final class LoginDetails {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return this.username;
|
||||
}
|
||||
|
||||
public AuthToken getAccessToken() {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
public RefreshToken getRefreshToken() {
|
||||
return this.refreshToken;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package app.mealsmadeeasy.api.auth;
|
||||
|
||||
public final class LoginException extends Exception {
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class LoginException extends Exception {
|
||||
|
||||
private final LoginExceptionReason reason;
|
||||
|
||||
@ -14,8 +17,4 @@ public final class LoginException extends Exception {
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public LoginExceptionReason getReason() {
|
||||
return this.reason;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
package app.mealsmadeeasy.api.auth;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class LoginExceptionView {
|
||||
|
||||
private final LoginExceptionReason reason;
|
||||
@ -10,12 +13,4 @@ public class LoginExceptionView {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public LoginExceptionReason getReason() {
|
||||
return this.reason;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return this.message;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
package app.mealsmadeeasy.api.auth;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class LoginView {
|
||||
@Getter
|
||||
public class LoginView {
|
||||
|
||||
private final String username;
|
||||
private final String accessToken;
|
||||
@ -14,16 +17,4 @@ public final class LoginView {
|
||||
this.expires = expires;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return this.username;
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
public LocalDateTime getExpires() {
|
||||
return this.expires;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,13 +1,40 @@
|
||||
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;
|
||||
|
||||
public interface RefreshToken {
|
||||
UUID getToken();
|
||||
long getLifetime();
|
||||
OffsetDateTime getExpires();
|
||||
OffsetDateTime getIssued();
|
||||
boolean isRevoked();
|
||||
boolean isDeleted();
|
||||
@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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -8,9 +8,9 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
|
||||
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, UUID> {
|
||||
|
||||
Optional<RefreshTokenEntity> findByToken(UUID token);
|
||||
Optional<RefreshToken> findByToken(UUID token);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
|
||||
36
src/main/java/app/mealsmadeeasy/api/file/File.java
Normal file
36
src/main/java/app/mealsmadeeasy/api/file/File.java
Normal file
@ -0,0 +1,36 @@
|
||||
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;
|
||||
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package app.mealsmadeeasy.api.file;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface FileRepository extends JpaRepository<File, UUID> {}
|
||||
75
src/main/java/app/mealsmadeeasy/api/file/FileService.java
Normal file
75
src/main/java/app/mealsmadeeasy/api/file/FileService.java
Normal file
@ -0,0 +1,75 @@
|
||||
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"
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,22 +1,58 @@
|
||||
package app.mealsmadeeasy.api.image;
|
||||
|
||||
import app.mealsmadeeasy.api.user.User;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
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();
|
||||
@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<>();
|
||||
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
package app.mealsmadeeasy.api.image;
|
||||
|
||||
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.body.ImageUpdateBody;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
|
||||
import app.mealsmadeeasy.api.image.view.ImageView;
|
||||
import app.mealsmadeeasy.api.user.User;
|
||||
import app.mealsmadeeasy.api.user.UserService;
|
||||
@ -34,27 +34,25 @@ public class ImageController {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
private ImageUpdateInfoSpec getImageUpdateSpec(ImageUpdateInfoBody body) {
|
||||
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||
spec.setAlt(body.getAlt());
|
||||
spec.setCaption(body.getCaption());
|
||||
spec.setPublic(body.getPublic());
|
||||
private ImageUpdateSpec getImageUpdateSpec(ImageUpdateBody body) {
|
||||
final var builder = ImageUpdateSpec.builder()
|
||||
.alt(body.getAlt())
|
||||
.caption(body.getCaption())
|
||||
.isPublic(body.getIsPublic())
|
||||
.clearAllViewers(body.getClearAllViewers());
|
||||
if (body.getViewersToAdd() != null) {
|
||||
spec.setViewersToAdd(
|
||||
body.getViewersToAdd().stream()
|
||||
builder.viewersToAdd(body.getViewersToAdd().stream()
|
||||
.map(this.userService::getUser)
|
||||
.collect(Collectors.toSet())
|
||||
);
|
||||
}
|
||||
if (body.getViewersToRemove() != null) {
|
||||
spec.setViewersToRemove(
|
||||
body.getViewersToRemove().stream()
|
||||
builder.viewersToRemove(body.getViewersToRemove().stream()
|
||||
.map(this.userService::getUser)
|
||||
.collect(Collectors.toSet())
|
||||
);
|
||||
}
|
||||
spec.setClearAllViewers(body.getClearAllViewers());
|
||||
return spec;
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ -97,19 +95,20 @@ public class ImageController {
|
||||
if (principal == null) {
|
||||
throw new AccessDeniedException("Must be logged in.");
|
||||
}
|
||||
final ImageCreateInfoSpec createSpec = new ImageCreateInfoSpec();
|
||||
createSpec.setAlt(alt);
|
||||
createSpec.setCaption(caption);
|
||||
createSpec.setPublic(isPublic);
|
||||
final var specBuilder = ImageCreateSpec.builder()
|
||||
.alt(alt)
|
||||
.caption(caption)
|
||||
.isPublic(isPublic);
|
||||
|
||||
if (viewers != null) {
|
||||
createSpec.setViewersToAdd(viewers.stream().map(this.userService::getUser).collect(Collectors.toSet()));
|
||||
specBuilder.viewersToAdd(viewers.stream().map(this.userService::getUser).collect(Collectors.toSet()));
|
||||
}
|
||||
final Image saved = this.imageService.create(
|
||||
principal,
|
||||
filename,
|
||||
image.getInputStream(),
|
||||
image.getSize(),
|
||||
createSpec
|
||||
specBuilder.build()
|
||||
);
|
||||
return ResponseEntity.status(201).body(this.imageService.toImageView(saved, principal));
|
||||
}
|
||||
@ -119,7 +118,7 @@ public class ImageController {
|
||||
@AuthenticationPrincipal User principal,
|
||||
@PathVariable String username,
|
||||
@PathVariable String filename,
|
||||
@RequestBody ImageUpdateInfoBody body
|
||||
@RequestBody ImageUpdateBody body
|
||||
) throws ImageException {
|
||||
if (principal == null) {
|
||||
throw new AccessDeniedException("Must be logged in.");
|
||||
|
||||
@ -8,16 +8,16 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface S3ImageRepository extends JpaRepository<S3ImageEntity, Long> {
|
||||
public interface ImageRepository extends JpaRepository<Image, Integer> {
|
||||
|
||||
@Query("SELECT image FROM Image image WHERE image.id = ?1")
|
||||
@EntityGraph(attributePaths = { "viewers" })
|
||||
S3ImageEntity getByIdWithViewers(long id);
|
||||
Image getByIdWithViewers(Integer id);
|
||||
|
||||
List<S3ImageEntity> findAllByOwner(User owner);
|
||||
Optional<S3ImageEntity> findByOwnerAndUserFilename(User owner, String filename);
|
||||
List<Image> findAllByOwner(User owner);
|
||||
Optional<Image> findByOwnerAndUserFilename(User owner, String filename);
|
||||
|
||||
@Query("SELECT image from Image image WHERE image.owner.username = ?1 AND image.userFilename = ?2")
|
||||
Optional<S3ImageEntity> findByOwnerUsernameAndFilename(String username, String filename);
|
||||
Optional<Image> findByOwnerUsernameAndFilename(String username, String filename);
|
||||
|
||||
}
|
||||
@ -9,15 +9,15 @@ import java.util.Objects;
|
||||
@Component("imageSecurity")
|
||||
public class ImageSecurityImpl implements ImageSecurity {
|
||||
|
||||
private final S3ImageRepository imageRepository;
|
||||
private final ImageRepository imageRepository;
|
||||
|
||||
public ImageSecurityImpl(S3ImageRepository imageRepository) {
|
||||
public ImageSecurityImpl(ImageRepository imageRepository) {
|
||||
this.imageRepository = imageRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isViewableBy(Image image, @Nullable User viewer) {
|
||||
if (image.isPublic()) {
|
||||
if (image.getIsPublic()) {
|
||||
// public image
|
||||
return true;
|
||||
} else if (viewer == null) {
|
||||
@ -28,7 +28,7 @@ public class ImageSecurityImpl implements ImageSecurity {
|
||||
return true;
|
||||
} else {
|
||||
// check if viewer
|
||||
final S3ImageEntity withViewers = this.imageRepository.getByIdWithViewers(image.getId());
|
||||
final Image withViewers = this.imageRepository.getByIdWithViewers(image.getId());
|
||||
for (final User user : withViewers.getViewers()) {
|
||||
if (user.getId() != null && user.getId().equals(viewer.getId())) {
|
||||
return true;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
package app.mealsmadeeasy.api.image;
|
||||
|
||||
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
|
||||
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, ImageCreateInfoSpec infoSpec)
|
||||
Image create(User owner, String userFilename, InputStream inputStream, long objectSize, ImageCreateSpec infoSpec)
|
||||
throws IOException, ImageException;
|
||||
|
||||
Image getById(long id, @Nullable User viewer) throws ImageException;
|
||||
Image getById(Integer 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, ImageUpdateInfoSpec spec);
|
||||
Image update(Image image, User modifier, ImageUpdateSpec spec);
|
||||
|
||||
void deleteImage(Image image, User modifier) throws IOException;
|
||||
|
||||
|
||||
@ -1,182 +0,0 @@
|
||||
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 + ")";
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
package app.mealsmadeeasy.api.image;
|
||||
|
||||
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
|
||||
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;
|
||||
@ -19,66 +20,31 @@ 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 S3ImageRepository imageRepository;
|
||||
private final ImageRepository imageRepository;
|
||||
private final String imageBucketName;
|
||||
private final String baseUrl;
|
||||
private final MimeTypeService mimeTypeService;
|
||||
|
||||
public S3ImageService(
|
||||
S3Manager s3Manager,
|
||||
S3ImageRepository imageRepository,
|
||||
ImageRepository imageRepository,
|
||||
@Value("${app.mealsmadeeasy.api.images.bucketName}") String imageBucketName,
|
||||
@Value("${app.mealsmadeeasy.api.baseUrl}") String baseUrl
|
||||
@Value("${app.mealsmadeeasy.api.baseUrl}") String baseUrl,
|
||||
MimeTypeService mimeTypeService
|
||||
) {
|
||||
this.s3Manager = s3Manager;
|
||||
this.imageRepository = imageRepository;
|
||||
this.imageBucketName = imageBucketName;
|
||||
this.baseUrl = baseUrl;
|
||||
this.mimeTypeService = mimeTypeService;
|
||||
}
|
||||
|
||||
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) {
|
||||
private boolean transferFromCreateSpec(Image entity, ImageCreateSpec spec) {
|
||||
boolean didTransfer = false;
|
||||
if (spec.getAlt() != null) {
|
||||
entity.setAlt(spec.getAlt());
|
||||
@ -88,22 +54,55 @@ public class S3ImageService implements ImageService {
|
||||
entity.setCaption(spec.getCaption());
|
||||
didTransfer = true;
|
||||
}
|
||||
if (spec.getPublic() != null) {
|
||||
entity.setPublic(spec.getPublic());
|
||||
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.getViewerEntities());
|
||||
for (final User viewerToAdd : spec.getViewersToAdd()) {
|
||||
viewers.add((User) viewerToAdd);
|
||||
}
|
||||
final Set<User> viewers = new HashSet<>(entity.getViewers());
|
||||
viewers.addAll(spec.getViewersToAdd());
|
||||
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}.
|
||||
*
|
||||
@ -122,11 +121,11 @@ public class S3ImageService implements ImageService {
|
||||
String userFilename,
|
||||
InputStream inputStream,
|
||||
long objectSize,
|
||||
ImageCreateInfoSpec createSpec
|
||||
ImageCreateSpec createSpec
|
||||
) throws IOException, ImageException {
|
||||
final String mimeType = this.getMimeType(userFilename);
|
||||
final String mimeType = this.mimeTypeService.getMimeType(userFilename);
|
||||
final String uuid = UUID.randomUUID().toString();
|
||||
final String extension = this.getExtension(mimeType);
|
||||
final String extension = this.mimeTypeService.getExtension(mimeType);
|
||||
final String filename = uuid + "." + extension;
|
||||
|
||||
final var baos = new ByteArrayOutputStream();
|
||||
@ -152,20 +151,20 @@ public class S3ImageService implements ImageService {
|
||||
toStore.close();
|
||||
inputStream.close();
|
||||
|
||||
final S3ImageEntity draft = new S3ImageEntity();
|
||||
draft.setOwner((User) owner);
|
||||
final Image draft = new Image();
|
||||
draft.setOwner(owner);
|
||||
draft.setUserFilename(userFilename);
|
||||
draft.setMimeType(mimeType);
|
||||
draft.setObjectName(objectName);
|
||||
draft.setHeight(height);
|
||||
draft.setWidth(width);
|
||||
this.transferFromSpec(draft, createSpec);
|
||||
this.transferFromCreateSpec(draft, createSpec);
|
||||
return this.imageRepository.save(draft);
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)")
|
||||
public Image getById(long id, @Nullable User viewer) throws ImageException {
|
||||
public Image getById(Integer id, @Nullable User viewer) throws ImageException {
|
||||
return this.imageRepository.findById(id).orElseThrow(() -> new ImageException(
|
||||
ImageException.Type.INVALID_ID, "No Image with id: " + id
|
||||
));
|
||||
@ -195,7 +194,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, ((S3ImageEntity) image).getObjectName());
|
||||
return this.s3Manager.load(this.imageBucketName, ((Image) image).getObjectName());
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -205,36 +204,19 @@ public class S3ImageService implements ImageService {
|
||||
|
||||
@Override
|
||||
@PreAuthorize("@imageSecurity.isOwner(#image, #modifier)")
|
||||
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;
|
||||
}
|
||||
}
|
||||
public Image update(final Image image, User modifier, ImageUpdateSpec updateSpec) {
|
||||
final boolean didUpdate = this.transferFromUpdateSpec(image, updateSpec);
|
||||
if (didUpdate) {
|
||||
entity.setModified(OffsetDateTime.now());
|
||||
image.setModified(OffsetDateTime.now());
|
||||
}
|
||||
return this.imageRepository.save(entity);
|
||||
return this.imageRepository.save(image);
|
||||
}
|
||||
|
||||
@Override
|
||||
@PreAuthorize("@imageSecurity.isOwner(#image, #modifier)")
|
||||
public void deleteImage(Image image, User modifier) throws IOException {
|
||||
final S3ImageEntity imageEntity = (S3ImageEntity) image;
|
||||
this.imageRepository.delete(imageEntity);
|
||||
this.s3Manager.delete("images", imageEntity.getObjectName());
|
||||
this.imageRepository.delete(image);
|
||||
this.s3Manager.delete("images", image.getObjectName());
|
||||
}
|
||||
|
||||
private String getImageUrl(Image image) {
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
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;
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
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;
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
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;
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
package app.mealsmadeeasy.api.image.view;
|
||||
|
||||
public class ImageExceptionView {
|
||||
|
||||
|
||||
}
|
||||
@ -2,143 +2,52 @@ 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 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());
|
||||
final var builder = ImageView.builder()
|
||||
.url(url)
|
||||
.created(image.getCreated())
|
||||
.modified(image.getModified())
|
||||
.filename(image.getUserFilename())
|
||||
.mimeType(image.getMimeType())
|
||||
.alt(image.getAlt())
|
||||
.caption(image.getCaption())
|
||||
.owner(UserInfoView.from(image.getOwner()))
|
||||
.isPublic(image.getIsPublic())
|
||||
.height(image.getHeight())
|
||||
.width(image.getWidth());
|
||||
if (includeViewers) {
|
||||
view.setViewers(image.getViewers().stream()
|
||||
builder.viewers(
|
||||
image.getViewers().stream()
|
||||
.map(UserInfoView::from)
|
||||
.collect(Collectors.toSet())
|
||||
);
|
||||
}
|
||||
return view;
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
60
src/main/java/app/mealsmadeeasy/api/job/Job.java
Normal file
60
src/main/java/app/mealsmadeeasy/api/job/Job.java
Normal file
@ -0,0 +1,60 @@
|
||||
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;
|
||||
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
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;
|
||||
|
||||
}
|
||||
7
src/main/java/app/mealsmadeeasy/api/job/JobHandler.java
Normal file
7
src/main/java/app/mealsmadeeasy/api/job/JobHandler.java
Normal file
@ -0,0 +1,7 @@
|
||||
package app.mealsmadeeasy.api.job;
|
||||
|
||||
public interface JobHandler<T> {
|
||||
Class<T> getPayloadType();
|
||||
String getJobKey();
|
||||
void handle(Job job, T payload);
|
||||
}
|
||||
27
src/main/java/app/mealsmadeeasy/api/job/JobRepository.java
Normal file
27
src/main/java/app/mealsmadeeasy/api/job/JobRepository.java
Normal file
@ -0,0 +1,27 @@
|
||||
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);
|
||||
|
||||
}
|
||||
119
src/main/java/app/mealsmadeeasy/api/job/JobService.java
Normal file
119
src/main/java/app/mealsmadeeasy/api/job/JobService.java
Normal file
@ -0,0 +1,119 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
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;
|
||||
@ -43,7 +42,7 @@ public final class JwtServiceImpl implements JwtService {
|
||||
.signWith(this.secretKey)
|
||||
.json(this.serializer)
|
||||
.compact();
|
||||
return new SimpleAuthToken(
|
||||
return new AuthToken(
|
||||
token,
|
||||
this.accessTokenLifetime,
|
||||
LocalDateTime.ofInstant(expires, ZoneId.systemDefault())
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package app.mealsmadeeasy.api.recipe;
|
||||
|
||||
import app.mealsmadeeasy.api.image.S3ImageEntity;
|
||||
import app.mealsmadeeasy.api.image.Image;
|
||||
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(name = "Recipe")
|
||||
@Entity
|
||||
@Data
|
||||
public final class Recipe {
|
||||
public class Recipe {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@ -75,9 +75,10 @@ public final class Recipe {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "main_image_id")
|
||||
private S3ImageEntity mainImage;
|
||||
@Nullable
|
||||
private Image mainImage;
|
||||
|
||||
@OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private RecipeEmbeddingEntity embedding;
|
||||
private RecipeEmbedding embedding;
|
||||
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
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;
|
||||
@ -94,10 +95,11 @@ public class RecipeController {
|
||||
@PathVariable String username,
|
||||
@PathVariable String slug,
|
||||
@RequestParam(defaultValue = "true") boolean includeRawText,
|
||||
@RequestBody RecipeUpdateSpec updateSpec,
|
||||
@RequestBody RecipeUpdateBody updateBody,
|
||||
@AuthenticationPrincipal User principal
|
||||
) throws ImageException, RecipeException {
|
||||
final Recipe updated = this.recipeService.update(username, slug, updateSpec, principal);
|
||||
final RecipeUpdateSpec spec = RecipeUpdateSpec.from(updateBody);
|
||||
final Recipe updated = this.recipeService.update(username, slug, spec, principal);
|
||||
final FullRecipeView view = this.recipeService.toFullRecipeView(updated, includeRawText, principal);
|
||||
return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, principal));
|
||||
}
|
||||
@ -117,9 +119,9 @@ public class RecipeController {
|
||||
@AuthenticationPrincipal User user
|
||||
) {
|
||||
if (recipeSearchBody.getType() == RecipeSearchBody.Type.AI_PROMPT) {
|
||||
final RecipeAiSearchSpec spec = this.objectMapper.convertValue(
|
||||
final RecipeAiSearchBody spec = this.objectMapper.convertValue(
|
||||
recipeSearchBody.getData(),
|
||||
RecipeAiSearchSpec.class
|
||||
RecipeAiSearchBody.class
|
||||
);
|
||||
final List<RecipeInfoView> results = this.recipeService.aiSearch(spec, user);
|
||||
return ResponseEntity.ok(Map.of("results", results));
|
||||
|
||||
63
src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java
Normal file
63
src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java
Normal file
@ -0,0 +1,63 @@
|
||||
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;
|
||||
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package app.mealsmadeeasy.api.recipe;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface RecipeDraftRepository extends JpaRepository<RecipeDraft, UUID> {}
|
||||
@ -0,0 +1,33 @@
|
||||
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;
|
||||
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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, Long> {
|
||||
public interface RecipeRepository extends JpaRepository<Recipe, Integer> {
|
||||
|
||||
List<Recipe> findAllByIsPublicIsTrue();
|
||||
|
||||
|
||||
@ -2,12 +2,82 @@ 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;
|
||||
}
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,63 +1,366 @@
|
||||
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.recipe.spec.RecipeAiSearchSpec;
|
||||
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.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;
|
||||
|
||||
public interface RecipeService {
|
||||
@Service
|
||||
public class RecipeService {
|
||||
|
||||
Recipe create(@Nullable User owner, RecipeCreateSpec spec);
|
||||
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 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 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;
|
||||
}
|
||||
|
||||
FullRecipeView getFullViewById(long id, @Nullable User viewer) throws RecipeException;
|
||||
FullRecipeView getFullViewByUsernameAndSlug(
|
||||
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(
|
||||
String username,
|
||||
String slug,
|
||||
boolean includeRawText,
|
||||
@Nullable User viewer
|
||||
) throws RecipeException;
|
||||
) 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);
|
||||
}
|
||||
|
||||
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 Slice<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer) {
|
||||
return this.recipeRepository.findAllViewableBy(viewer, pageable).map(recipe ->
|
||||
this.getInfoView(recipe, viewer)
|
||||
);
|
||||
}
|
||||
|
||||
List<RecipeInfoView> aiSearch(RecipeAiSearchSpec searchSpec, @Nullable User viewer);
|
||||
public List<Recipe> getByMinimumStars(long minimumStars, @Nullable User viewer) {
|
||||
return List.copyOf(
|
||||
this.recipeRepository.findAllViewableByStarsGreaterThanEqual(minimumStars, viewer)
|
||||
);
|
||||
}
|
||||
|
||||
Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
|
||||
throws RecipeException, ImageException;
|
||||
public List<Recipe> getPublicRecipes() {
|
||||
return List.copyOf(this.recipeRepository.findAllByIsPublicIsTrue());
|
||||
}
|
||||
|
||||
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> getRecipesViewableBy(User viewer) {
|
||||
return List.copyOf(this.recipeRepository.findAllByViewersContaining(viewer));
|
||||
}
|
||||
|
||||
void deleteRecipe(long id, User modifier);
|
||||
public List<Recipe> getRecipesOwnedBy(User owner) {
|
||||
return List.copyOf(this.recipeRepository.findAllByOwner(owner));
|
||||
}
|
||||
|
||||
FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer);
|
||||
RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer);
|
||||
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();
|
||||
}
|
||||
|
||||
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")
|
||||
@Nullable Boolean isStarer(String username, String slug, @Nullable User viewer);
|
||||
public @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer) {
|
||||
if (viewer == null) {
|
||||
return null;
|
||||
}
|
||||
return this.recipeStarRepository.isStarer(username, slug, viewer.getId());
|
||||
}
|
||||
|
||||
@PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
|
||||
@Contract("_, _, null -> null")
|
||||
@Nullable Boolean isOwner(String username, String slug, @Nullable User viewer);
|
||||
public @Nullable Boolean isOwner(String username, String slug, @Nullable User viewer) {
|
||||
if (viewer == null) {
|
||||
return null;
|
||||
}
|
||||
return viewer.getUsername().equals(username);
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
String getRenderedMarkdown(Recipe entity);
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,316 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package app.mealsmadeeasy.api.recipe.body;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RecipeAiSearchBody {
|
||||
private String prompt;
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
package app.mealsmadeeasy.api.recipe.body;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class RecipeSearchBody {
|
||||
|
||||
public enum Type {
|
||||
@ -11,20 +14,4 @@ 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
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;
|
||||
|
||||
}
|
||||
@ -8,6 +8,7 @@ import lombok.Data;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "recipe_comment")
|
||||
@Data
|
||||
public final class RecipeComment {
|
||||
|
||||
|
||||
@ -1,15 +1,8 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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, Long> {
|
||||
public interface RecipeCommentRepository extends JpaRepository<RecipeComment, Integer> {
|
||||
void deleteAllByRecipe(Recipe recipe);
|
||||
Slice<RecipeComment> findAllByRecipe(Recipe recipe, Pageable pageable);
|
||||
}
|
||||
|
||||
@ -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(long commentId, User viewer) throws RecipeException;
|
||||
RecipeComment get(Integer commentId, User viewer) throws RecipeException;
|
||||
Slice<RecipeCommentView> getComments(String recipeUsername, String recipeSlug, Pageable pageable, User viewer)
|
||||
throws RecipeException;
|
||||
RecipeComment update(long commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException;
|
||||
void delete(long commentId, User modifier) throws RecipeException;
|
||||
RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException;
|
||||
void delete(Integer commentId, User modifier) throws RecipeException;
|
||||
}
|
||||
|
||||
@ -56,14 +56,14 @@ public class RecipeCommentServiceImpl implements RecipeCommentService {
|
||||
}
|
||||
|
||||
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject.recipe, #viewer)")
|
||||
private RecipeComment loadCommentEntity(long commentId, User viewer) throws RecipeException {
|
||||
private RecipeComment loadCommentEntity(Integer 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(long commentId, User viewer) throws RecipeException {
|
||||
public RecipeComment get(Integer commentId, User viewer) throws RecipeException {
|
||||
return this.loadCommentEntity(commentId, viewer);
|
||||
}
|
||||
|
||||
@ -84,21 +84,21 @@ public class RecipeCommentServiceImpl implements RecipeCommentService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public RecipeComment update(long commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException {
|
||||
public RecipeComment update(Integer 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(long commentId, User modifier) throws RecipeException {
|
||||
private RecipeComment loadForDelete(Integer 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(long commentId, User modifier) throws RecipeException {
|
||||
public void delete(Integer commentId, User modifier) throws RecipeException {
|
||||
final RecipeComment entityToDelete = this.loadForDelete(commentId, modifier);
|
||||
this.recipeCommentRepository.delete(entityToDelete);
|
||||
}
|
||||
|
||||
@ -1,15 +1,10 @@
|
||||
package app.mealsmadeeasy.api.recipe.comment;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
@Builder
|
||||
public class RecipeCommentUpdateSpec {
|
||||
|
||||
private String rawText;
|
||||
|
||||
public String getRawText() {
|
||||
return this.rawText;
|
||||
}
|
||||
|
||||
public void setRawText(String rawText) {
|
||||
this.rawText = rawText;
|
||||
}
|
||||
|
||||
String rawText;
|
||||
}
|
||||
|
||||
@ -1,88 +1,36 @@
|
||||
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 RecipeCommentView view = new RecipeCommentView();
|
||||
view.setId(comment.getId());
|
||||
view.setCreated(comment.getCreated());
|
||||
view.setModified(comment.getModified());
|
||||
view.setText(((RecipeComment) comment).getCachedRenderedText());
|
||||
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());
|
||||
if (includeRawText) {
|
||||
view.setRawText(comment.getRawText());
|
||||
builder.rawText(comment.getRawText());
|
||||
}
|
||||
view.setOwner(UserInfoView.from(comment.getOwner()));
|
||||
view.setRecipeId(comment.getRecipe().getId());
|
||||
return view;
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Integer id;
|
||||
OffsetDateTime created;
|
||||
@Nullable OffsetDateTime modified;
|
||||
String text;
|
||||
@Nullable String rawText;
|
||||
UserInfoView owner;
|
||||
Integer recipeId;
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package app.mealsmadeeasy.api.recipe.job;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record RecipeInferJobPayload(UUID recipeDraftId, UUID fileId) {}
|
||||
@ -1,15 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,81 +1,19 @@
|
||||
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 {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
String slug;
|
||||
String title;
|
||||
@Nullable Integer preparationTime;
|
||||
@Nullable Integer cookingTime;
|
||||
@Nullable Integer totalTime;
|
||||
String rawText;
|
||||
boolean isPublic;
|
||||
@Nullable Image mainImage;
|
||||
}
|
||||
|
||||
@ -2,120 +2,73 @@ 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 {
|
||||
|
||||
private String username;
|
||||
private String filename;
|
||||
|
||||
public String getUsername() {
|
||||
return this.username;
|
||||
String username;
|
||||
String filename;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getFilename() {
|
||||
return this.filename;
|
||||
}
|
||||
|
||||
public void setFilename(String filename) {
|
||||
this.filename = filename;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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();
|
||||
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) {
|
||||
this.mainImage = new MainImageUpdateSpec();
|
||||
this.mainImage.setUsername(mainImage.getOwner().getUsername());
|
||||
this.mainImage.setFilename(mainImage.getUserFilename());
|
||||
b.mainImage(
|
||||
MainImageUpdateSpec.builder()
|
||||
.username(mainImage.getUsername())
|
||||
.filename(mainImage.getFilename())
|
||||
.build()
|
||||
);
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
|
||||
public @Nullable String getTitle() {
|
||||
return this.title;
|
||||
// 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());
|
||||
final @Nullable Image mainImage = recipe.getMainImage();
|
||||
if (recipe.getMainImage() != null) {
|
||||
b.mainImage(MainImageUpdateSpec.builder()
|
||||
.username(mainImage.getOwner().getUsername())
|
||||
.filename(mainImage.getUserFilename())
|
||||
.build()
|
||||
);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
String title;
|
||||
@Nullable Integer preparationTime;
|
||||
@Nullable Integer cookingTime;
|
||||
@Nullable Integer totalTime;
|
||||
String rawText;
|
||||
Boolean isPublic;
|
||||
@Nullable MainImageUpdateSpec mainImage;
|
||||
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import lombok.Data;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity(name = "RecipeStar")
|
||||
@Entity
|
||||
@Table(name = "recipe_star")
|
||||
@Data
|
||||
public final class RecipeStar {
|
||||
|
||||
@ -2,10 +2,10 @@ package app.mealsmadeeasy.api.recipe.star;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Embeddable;
|
||||
|
||||
import java.util.Objects;
|
||||
import lombok.Data;
|
||||
|
||||
@Embeddable
|
||||
@Data
|
||||
public class RecipeStarId {
|
||||
|
||||
@Column(name = "owner_id", nullable = false)
|
||||
@ -14,39 +14,4 @@ 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 + ")";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ public class RecipeStarServiceImpl implements RecipeStarService {
|
||||
final RecipeStar draft = new RecipeStar();
|
||||
final RecipeStarId id = new RecipeStarId();
|
||||
id.setRecipeId(recipeId);
|
||||
id.getOwnerId(ownerId);
|
||||
id.setOwnerId(ownerId);
|
||||
draft.setId(id);
|
||||
draft.setTimestamp(OffsetDateTime.now());
|
||||
return this.recipeStarRepository.save(draft);
|
||||
|
||||
@ -4,11 +4,14 @@ 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 com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Value
|
||||
@Builder
|
||||
public class FullRecipeView {
|
||||
|
||||
public static FullRecipeView from(
|
||||
@ -19,162 +22,46 @@ public class FullRecipeView {
|
||||
int viewerCount,
|
||||
@Nullable ImageView mainImage
|
||||
) {
|
||||
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);
|
||||
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());
|
||||
if (includeRawText) {
|
||||
view.setRawText(recipe.getRawText());
|
||||
b.rawText(recipe.getRawText());
|
||||
}
|
||||
view.setOwner(UserInfoView.from(recipe.getOwner()));
|
||||
view.setStarCount(starCount);
|
||||
view.setViewerCount(viewerCount);
|
||||
view.setMainImage(mainImage);
|
||||
view.setIsPublic(recipe.getIsPublic());
|
||||
return view;
|
||||
return b.build();
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
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)
|
||||
@JsonInclude(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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
package app.mealsmadeeasy.api.recipe.view;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public final class RecipeExceptionView {
|
||||
|
||||
private final String type;
|
||||
@ -10,12 +13,4 @@ public final class RecipeExceptionView {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return this.message;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -3,42 +3,44 @@ 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.Data;
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Data
|
||||
@Value
|
||||
@Builder
|
||||
public class RecipeInfoView {
|
||||
|
||||
public static RecipeInfoView from(Recipe recipe, int starCount, @Nullable ImageView mainImage) {
|
||||
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;
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
package app.mealsmadeeasy.api.security;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public interface AuthToken {
|
||||
String getToken();
|
||||
long getLifetime();
|
||||
LocalDateTime getExpires();
|
||||
@Value
|
||||
public class AuthToken {
|
||||
String token;
|
||||
long lifetime;
|
||||
LocalDateTime expires;
|
||||
}
|
||||
|
||||
@ -1,31 +1,16 @@
|
||||
package app.mealsmadeeasy.api.security;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
public class SecurityExceptionView {
|
||||
|
||||
public enum Action {
|
||||
LOGIN, REFRESH
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
int status;
|
||||
Action action;
|
||||
String message;
|
||||
|
||||
}
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -12,7 +12,7 @@ import java.util.Set;
|
||||
@Entity(name = "User")
|
||||
@Table(name = "\"user\"")
|
||||
@Data
|
||||
public final class User implements UserDetails {
|
||||
public class User implements UserDetails {
|
||||
|
||||
public static User getDefaultDraft() {
|
||||
final var user = new User();
|
||||
|
||||
@ -4,10 +4,10 @@ import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
@Entity(name = "UserGrantedAuthority")
|
||||
@Entity
|
||||
@Table(name = "user_granted_authority")
|
||||
@Data
|
||||
public final class UserGrantedAuthority implements GrantedAuthority {
|
||||
public class UserGrantedAuthority implements GrantedAuthority {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@ -2,4 +2,4 @@ package app.mealsmadeeasy.api.user;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface UserGrantedAuthorityRepository extends JpaRepository<UserGrantedAuthority, Long> {}
|
||||
public interface UserGrantedAuthorityRepository extends JpaRepository<UserGrantedAuthority, Integer> {}
|
||||
|
||||
@ -1,33 +1,16 @@
|
||||
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) {
|
||||
final UserInfoView userInfoView = new UserInfoView();
|
||||
userInfoView.setId(user.getId());
|
||||
userInfoView.setUsername(user.getUsername());
|
||||
return userInfoView;
|
||||
return new UserInfoView(user.getId(), user.getUsername());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Integer id;
|
||||
String username;
|
||||
|
||||
}
|
||||
|
||||
@ -1,21 +1,9 @@
|
||||
package app.mealsmadeeasy.api.util;
|
||||
|
||||
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;
|
||||
}
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
public class AccessDeniedView {
|
||||
int statusCode;
|
||||
String message;
|
||||
}
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
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
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@ -16,6 +16,7 @@ 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
|
||||
|
||||
17
src/main/resources/db/migration/V5__create_recipe_draft.sql
Normal file
17
src/main/resources/db/migration/V5__create_recipe_draft.sql
Normal file
@ -0,0 +1,17 @@
|
||||
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
|
||||
);
|
||||
14
src/main/resources/db/migration/V6__create_job.sql
Normal file
14
src/main/resources/db/migration/V6__create_job.sql
Normal file
@ -0,0 +1,14 @@
|
||||
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
|
||||
);
|
||||
9
src/main/resources/db/migration/V7__create_file.sql
Normal file
9
src/main/resources/db/migration/V7__create_file.sql
Normal file
@ -0,0 +1,9 @@
|
||||
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"
|
||||
);
|
||||
98
src/test/java/app/mealsmadeeasy/api/job/JobServiceTests.java
Normal file
98
src/test/java/app/mealsmadeeasy/api/job/JobServiceTests.java
Normal file
@ -0,0 +1,98 @@
|
||||
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()));
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user