Compare commits
No commits in common. "7f985f3434538f7ac007a24c76f33119d99d98b5" and "0ad45adac1645cc3b0d93f1a979e697b7134b4df" have entirely different histories.
7f985f3434
...
0ad45adac1
@ -80,9 +80,6 @@ dependencies {
|
|||||||
implementation 'org.jsoup:jsoup:1.21.2'
|
implementation 'org.jsoup:jsoup:1.21.2'
|
||||||
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1'
|
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'
|
implementation 'io.minio:minio:8.6.0'
|
||||||
|
|
||||||
compileOnly 'org.jetbrains:annotations:26.0.2-1'
|
compileOnly 'org.jetbrains:annotations:26.0.2-1'
|
||||||
@ -101,10 +98,6 @@ dependencies {
|
|||||||
testImplementation 'org.testcontainers:junit-jupiter:1.21.4'
|
testImplementation 'org.testcontainers:junit-jupiter:1.21.4'
|
||||||
testImplementation 'org.testcontainers:postgresql:1.21.4'
|
testImplementation 'org.testcontainers:postgresql:1.21.4'
|
||||||
testImplementation 'org.testcontainers:minio: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'
|
testFixturesImplementation 'org.hamcrest:hamcrest:3.0'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,9 @@ package app.mealsmadeeasy.api.image;
|
|||||||
import app.mealsmadeeasy.api.IntegrationTestsExtension;
|
import app.mealsmadeeasy.api.IntegrationTestsExtension;
|
||||||
import app.mealsmadeeasy.api.auth.AuthService;
|
import app.mealsmadeeasy.api.auth.AuthService;
|
||||||
import app.mealsmadeeasy.api.auth.LoginException;
|
import app.mealsmadeeasy.api.auth.LoginException;
|
||||||
import app.mealsmadeeasy.api.image.body.ImageUpdateBody;
|
import app.mealsmadeeasy.api.image.body.ImageUpdateInfoBody;
|
||||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
|
||||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
|
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
import app.mealsmadeeasy.api.user.UserCreateException;
|
import app.mealsmadeeasy.api.user.UserCreateException;
|
||||||
import app.mealsmadeeasy.api.user.UserService;
|
import app.mealsmadeeasy.api.user.UserService;
|
||||||
@ -89,7 +89,7 @@ public class ImageControllerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Image seedImage(User owner, ImageCreateSpec spec) {
|
private Image seedImage(User owner, ImageCreateInfoSpec spec) {
|
||||||
try {
|
try {
|
||||||
return this.imageService.create(
|
return this.imageService.create(
|
||||||
owner,
|
owner,
|
||||||
@ -104,7 +104,7 @@ public class ImageControllerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Image seedImage(User owner) {
|
private Image seedImage(User owner) {
|
||||||
return this.seedImage(owner, ImageCreateSpec.builder().build());
|
return this.seedImage(owner, new ImageCreateInfoSpec());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String getImageUrl(User owner, Image image) {
|
private static String getImageUrl(User owner, Image image) {
|
||||||
@ -122,9 +122,8 @@ public class ImageControllerTests {
|
|||||||
@Test
|
@Test
|
||||||
public void getPublicImageNoPrincipal() throws Exception {
|
public void getPublicImageNoPrincipal() throws Exception {
|
||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
final ImageCreateSpec spec = ImageCreateSpec.builder()
|
final ImageCreateInfoSpec spec = new ImageCreateInfoSpec();
|
||||||
.isPublic(true)
|
spec.setPublic(true);
|
||||||
.build();
|
|
||||||
final Image image = this.seedImage(owner, spec);
|
final Image image = this.seedImage(owner, spec);
|
||||||
|
|
||||||
// Assert bytes the same and proper mime type
|
// Assert bytes the same and proper mime type
|
||||||
@ -162,9 +161,8 @@ public class ImageControllerTests {
|
|||||||
final Image image = this.seedImage(owner);
|
final Image image = this.seedImage(owner);
|
||||||
|
|
||||||
// add viewer
|
// add viewer
|
||||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||||
.viewersToAdd(Set.of(viewer))
|
spec.setViewersToAdd(Set.of(viewer));
|
||||||
.build();
|
|
||||||
this.imageService.update(image, owner, spec);
|
this.imageService.update(image, owner, spec);
|
||||||
|
|
||||||
this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(viewer));
|
this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(viewer));
|
||||||
@ -197,9 +195,8 @@ public class ImageControllerTests {
|
|||||||
final User viewer = this.seedUser();
|
final User viewer = this.seedUser();
|
||||||
|
|
||||||
// add viewer
|
// add viewer
|
||||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||||
.viewersToAdd(Set.of(viewer))
|
spec.setViewersToAdd(Set.of(viewer));
|
||||||
.build();
|
|
||||||
this.imageService.update(image, owner, spec);
|
this.imageService.update(image, owner, spec);
|
||||||
|
|
||||||
this.mockMvc.perform(get(getImageUrl(owner, image))).andExpect(status().isForbidden());
|
this.mockMvc.perform(get(getImageUrl(owner, image))).andExpect(status().isForbidden());
|
||||||
@ -234,7 +231,7 @@ public class ImageControllerTests {
|
|||||||
.andExpect(jsonPath("$.mimeType").value("image/svg+xml"))
|
.andExpect(jsonPath("$.mimeType").value("image/svg+xml"))
|
||||||
.andExpect(jsonPath("$.alt").value("HAL 9000"))
|
.andExpect(jsonPath("$.alt").value("HAL 9000"))
|
||||||
.andExpect(jsonPath("$.caption").value("HAL 9000, from 2001: A Space Odyssey"))
|
.andExpect(jsonPath("$.caption").value("HAL 9000, from 2001: A Space Odyssey"))
|
||||||
.andExpect(jsonPath("$.public").value(true))
|
.andExpect(jsonPath("$.isPublic").value(true))
|
||||||
.andExpect(jsonPath("$.owner.username").value(owner.getUsername()))
|
.andExpect(jsonPath("$.owner.username").value(owner.getUsername()))
|
||||||
.andExpect(jsonPath("$.owner.id").value(owner.getId()))
|
.andExpect(jsonPath("$.owner.id").value(owner.getId()))
|
||||||
.andExpect(jsonPath("$.viewers").value(empty()));
|
.andExpect(jsonPath("$.viewers").value(empty()));
|
||||||
@ -246,7 +243,7 @@ public class ImageControllerTests {
|
|||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
final Image image = this.seedImage(owner);
|
final Image image = this.seedImage(owner);
|
||||||
final String accessToken = this.getAccessToken(owner);
|
final String accessToken = this.getAccessToken(owner);
|
||||||
final ImageUpdateBody body = new ImageUpdateBody();
|
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||||
body.setAlt("HAL 9000");
|
body.setAlt("HAL 9000");
|
||||||
this.mockMvc.perform(
|
this.mockMvc.perform(
|
||||||
post(getImageUrl(owner, image))
|
post(getImageUrl(owner, image))
|
||||||
@ -264,7 +261,7 @@ public class ImageControllerTests {
|
|||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
final Image image = this.seedImage(owner);
|
final Image image = this.seedImage(owner);
|
||||||
final String accessToken = this.getAccessToken(owner);
|
final String accessToken = this.getAccessToken(owner);
|
||||||
final ImageUpdateBody body = new ImageUpdateBody();
|
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||||
body.setCaption("HAL 9000 from 2001: A Space Odyssey");
|
body.setCaption("HAL 9000 from 2001: A Space Odyssey");
|
||||||
this.mockMvc.perform(
|
this.mockMvc.perform(
|
||||||
post(getImageUrl(owner, image))
|
post(getImageUrl(owner, image))
|
||||||
@ -282,8 +279,8 @@ public class ImageControllerTests {
|
|||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
final Image image = this.seedImage(owner);
|
final Image image = this.seedImage(owner);
|
||||||
final String accessToken = this.getAccessToken(owner);
|
final String accessToken = this.getAccessToken(owner);
|
||||||
final ImageUpdateBody body = new ImageUpdateBody();
|
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||||
body.setIsPublic(true);
|
body.setPublic(true);
|
||||||
this.mockMvc.perform(
|
this.mockMvc.perform(
|
||||||
post(getImageUrl(owner, image))
|
post(getImageUrl(owner, image))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@ -292,7 +289,7 @@ public class ImageControllerTests {
|
|||||||
)
|
)
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.modified").value(notNullValue()))
|
.andExpect(jsonPath("$.modified").value(notNullValue()))
|
||||||
.andExpect(jsonPath("$.public").value(true));
|
.andExpect(jsonPath("$.isPublic").value(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -302,7 +299,7 @@ public class ImageControllerTests {
|
|||||||
final Image image = this.seedImage(owner);
|
final Image image = this.seedImage(owner);
|
||||||
final String accessToken = this.getAccessToken(owner);
|
final String accessToken = this.getAccessToken(owner);
|
||||||
|
|
||||||
final ImageUpdateBody body = new ImageUpdateBody();
|
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||||
final Set<String> viewerUsernames = Set.of(viewerToAdd.getUsername());
|
final Set<String> viewerUsernames = Set.of(viewerToAdd.getUsername());
|
||||||
body.setViewersToAdd(viewerUsernames);
|
body.setViewersToAdd(viewerUsernames);
|
||||||
|
|
||||||
@ -324,9 +321,8 @@ public class ImageControllerTests {
|
|||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
final User viewer = this.seedUser();
|
final User viewer = this.seedUser();
|
||||||
final Image image = this.seedImage(owner);
|
final Image image = this.seedImage(owner);
|
||||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||||
.viewersToAdd(Set.of(viewer))
|
spec.setViewersToAdd(Set.of(viewer));
|
||||||
.build();
|
|
||||||
this.imageService.update(image, owner, spec);
|
this.imageService.update(image, owner, spec);
|
||||||
return new OwnerViewerImage(owner, viewer, image);
|
return new OwnerViewerImage(owner, viewer, image);
|
||||||
}
|
}
|
||||||
@ -336,7 +332,7 @@ public class ImageControllerTests {
|
|||||||
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
|
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
|
||||||
final String accessToken = this.getAccessToken(ownerViewerImage.owner());
|
final String accessToken = this.getAccessToken(ownerViewerImage.owner());
|
||||||
|
|
||||||
final ImageUpdateBody body = new ImageUpdateBody();
|
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||||
body.setViewersToRemove(Set.of(ownerViewerImage.viewer().getUsername()));
|
body.setViewersToRemove(Set.of(ownerViewerImage.viewer().getUsername()));
|
||||||
|
|
||||||
this.mockMvc.perform(
|
this.mockMvc.perform(
|
||||||
@ -354,7 +350,7 @@ public class ImageControllerTests {
|
|||||||
public void clearAllViewers() throws Exception {
|
public void clearAllViewers() throws Exception {
|
||||||
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
|
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
|
||||||
final String accessToken = this.getAccessToken(ownerViewerImage.owner());
|
final String accessToken = this.getAccessToken(ownerViewerImage.owner());
|
||||||
final ImageUpdateBody body = new ImageUpdateBody();
|
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||||
body.setClearAllViewers(true);
|
body.setClearAllViewers(true);
|
||||||
this.mockMvc.perform(
|
this.mockMvc.perform(
|
||||||
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
|
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
|
||||||
@ -371,7 +367,7 @@ public class ImageControllerTests {
|
|||||||
public void updateInfoByViewerForbidden() throws Exception {
|
public void updateInfoByViewerForbidden() throws Exception {
|
||||||
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
|
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
|
||||||
final String accessToken = this.getAccessToken(ownerViewerImage.viewer()); // viewer
|
final String accessToken = this.getAccessToken(ownerViewerImage.viewer()); // viewer
|
||||||
final ImageUpdateBody body = new ImageUpdateBody();
|
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
|
||||||
this.mockMvc.perform(
|
this.mockMvc.perform(
|
||||||
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
|
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
package app.mealsmadeeasy.api.image;
|
package app.mealsmadeeasy.api.image;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.IntegrationTestsExtension;
|
import app.mealsmadeeasy.api.IntegrationTestsExtension;
|
||||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
|
||||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
|
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
import app.mealsmadeeasy.api.user.UserCreateException;
|
import app.mealsmadeeasy.api.user.UserCreateException;
|
||||||
import app.mealsmadeeasy.api.user.UserService;
|
import app.mealsmadeeasy.api.user.UserService;
|
||||||
@ -77,7 +77,7 @@ public class S3ImageServiceTests {
|
|||||||
return UUID.randomUUID() + ".svg";
|
return UUID.randomUUID() + ".svg";
|
||||||
}
|
}
|
||||||
|
|
||||||
private Image seedImage(User owner, ImageCreateSpec spec) {
|
private Image seedImage(User owner, ImageCreateInfoSpec spec) {
|
||||||
try (final InputStream hal9000 = getHal9000InputStream()) {
|
try (final InputStream hal9000 = getHal9000InputStream()) {
|
||||||
return this.imageService.create(
|
return this.imageService.create(
|
||||||
owner,
|
owner,
|
||||||
@ -92,13 +92,12 @@ public class S3ImageServiceTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Image seedImage(User owner) {
|
private Image seedImage(User owner) {
|
||||||
return this.seedImage(owner, ImageCreateSpec.builder().build());
|
return this.seedImage(owner, new ImageCreateInfoSpec());
|
||||||
}
|
}
|
||||||
|
|
||||||
private Image makePublic(Image image, User modifier) {
|
private Image makePublic(Image image, User modifier) {
|
||||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||||
.isPublic(true)
|
spec.setPublic(true);
|
||||||
.build();
|
|
||||||
return this.imageService.update(image, modifier, spec);
|
return this.imageService.update(image, modifier, spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,7 +115,7 @@ public class S3ImageServiceTests {
|
|||||||
assertThat(image.getMimeType(), is("image/svg+xml"));
|
assertThat(image.getMimeType(), is("image/svg+xml"));
|
||||||
assertThat(image.getAlt(), is(nullValue()));
|
assertThat(image.getAlt(), is(nullValue()));
|
||||||
assertThat(image.getCaption(), is(nullValue()));
|
assertThat(image.getCaption(), is(nullValue()));
|
||||||
assertThat(image.getIsPublic(), is(false));
|
assertThat(image.isPublic(), is(false));
|
||||||
assertThat(image.getViewers(), is(empty()));
|
assertThat(image.getViewers(), is(empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,6 +124,7 @@ public class S3ImageServiceTests {
|
|||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
final Image image = this.seedImage(owner);
|
final Image image = this.seedImage(owner);
|
||||||
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(image, owner));
|
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(image, owner));
|
||||||
|
//noinspection DataFlowIssue
|
||||||
final byte[] contentBytes = content.readAllBytes();
|
final byte[] contentBytes = content.readAllBytes();
|
||||||
assertThat(contentBytes.length, is((int) HAL_LENGTH));
|
assertThat(contentBytes.length, is((int) HAL_LENGTH));
|
||||||
content.close();
|
content.close();
|
||||||
@ -135,6 +135,7 @@ public class S3ImageServiceTests {
|
|||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
final Image image = this.seedImage(owner);
|
final Image image = this.seedImage(owner);
|
||||||
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(image, owner));
|
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(image, owner));
|
||||||
|
//noinspection DataFlowIssue
|
||||||
content.close();
|
content.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,6 +145,7 @@ public class S3ImageServiceTests {
|
|||||||
final Image seedImage = this.seedImage(owner);
|
final Image seedImage = this.seedImage(owner);
|
||||||
final Image publicImage = this.makePublic(seedImage, owner);
|
final Image publicImage = this.makePublic(seedImage, owner);
|
||||||
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(publicImage, null));
|
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(publicImage, null));
|
||||||
|
//noinspection DataFlowIssue
|
||||||
content.close();
|
content.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,11 +154,11 @@ public class S3ImageServiceTests {
|
|||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
final User viewer = this.seedUser();
|
final User viewer = this.seedUser();
|
||||||
Image seedImage = this.seedImage(owner);
|
Image seedImage = this.seedImage(owner);
|
||||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||||
.viewersToAdd(Set.of(viewer))
|
spec.setViewersToAdd(Set.of(viewer));
|
||||||
.build();
|
|
||||||
final Image imageWithViewer = this.imageService.update(seedImage, owner, spec);
|
final Image imageWithViewer = this.imageService.update(seedImage, owner, spec);
|
||||||
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(imageWithViewer, viewer));
|
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(imageWithViewer, viewer));
|
||||||
|
//noinspection DataFlowIssue
|
||||||
content.close();
|
content.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,9 +183,8 @@ public class S3ImageServiceTests {
|
|||||||
public void updateAlt() {
|
public void updateAlt() {
|
||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
Image image = this.seedImage(owner);
|
Image image = this.seedImage(owner);
|
||||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||||
.alt("HAL 9000")
|
spec.setAlt("HAL 9000");
|
||||||
.build();
|
|
||||||
image = this.imageService.update(image, owner, spec);
|
image = this.imageService.update(image, owner, spec);
|
||||||
assertThat(image.getAlt(), is("HAL 9000"));
|
assertThat(image.getAlt(), is("HAL 9000"));
|
||||||
}
|
}
|
||||||
@ -192,9 +193,8 @@ public class S3ImageServiceTests {
|
|||||||
public void updateCaption() {
|
public void updateCaption() {
|
||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
Image image = this.seedImage(owner);
|
Image image = this.seedImage(owner);
|
||||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||||
.caption("HAL 9000 from 2001: A Space Odyssey")
|
spec.setCaption("HAL 9000 from 2001: A Space Odyssey");
|
||||||
.build();
|
|
||||||
image = this.imageService.update(image, owner, spec);
|
image = this.imageService.update(image, owner, spec);
|
||||||
assertThat(image.getCaption(), is("HAL 9000 from 2001: A Space Odyssey"));
|
assertThat(image.getCaption(), is("HAL 9000 from 2001: A Space Odyssey"));
|
||||||
}
|
}
|
||||||
@ -203,17 +203,15 @@ public class S3ImageServiceTests {
|
|||||||
public void updateIsPublic() {
|
public void updateIsPublic() {
|
||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
Image image = this.seedImage(owner);
|
Image image = this.seedImage(owner);
|
||||||
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
|
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||||
.isPublic(true)
|
spec.setPublic(true);
|
||||||
.build();
|
|
||||||
image = this.imageService.update(image, owner, spec);
|
image = this.imageService.update(image, owner, spec);
|
||||||
assertThat(image.getIsPublic(), is(true));
|
assertThat(image.isPublic(), is(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Image addViewer(Image image, User owner, User viewer) {
|
private Image addViewer(Image image, User owner, User viewer) {
|
||||||
final ImageUpdateSpec spec0 = ImageUpdateSpec.builder()
|
final ImageUpdateInfoSpec spec0 = new ImageUpdateInfoSpec();
|
||||||
.viewersToAdd(Set.of(viewer))
|
spec0.setViewersToAdd(Set.of(viewer));
|
||||||
.build();
|
|
||||||
return this.imageService.update(image, owner, spec0);
|
return this.imageService.update(image, owner, spec0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,9 +232,8 @@ public class S3ImageServiceTests {
|
|||||||
image = this.addViewer(image, owner, viewer);
|
image = this.addViewer(image, owner, viewer);
|
||||||
assertThat(image.getViewers(), containsUsers(viewer));
|
assertThat(image.getViewers(), containsUsers(viewer));
|
||||||
|
|
||||||
final ImageUpdateSpec spec1 = ImageUpdateSpec.builder()
|
final ImageUpdateInfoSpec spec1 = new ImageUpdateInfoSpec();
|
||||||
.viewersToRemove(Set.of(viewer))
|
spec1.setViewersToRemove(Set.of(viewer));
|
||||||
.build();
|
|
||||||
image = this.imageService.update(image, owner, spec1);
|
image = this.imageService.update(image, owner, spec1);
|
||||||
assertThat(image.getViewers(), empty());
|
assertThat(image.getViewers(), empty());
|
||||||
}
|
}
|
||||||
@ -249,9 +246,8 @@ public class S3ImageServiceTests {
|
|||||||
image = this.addViewer(image, owner, viewer);
|
image = this.addViewer(image, owner, viewer);
|
||||||
assertThat(image.getViewers(), containsUsers(viewer));
|
assertThat(image.getViewers(), containsUsers(viewer));
|
||||||
|
|
||||||
final ImageUpdateSpec spec1 = ImageUpdateSpec.builder()
|
final ImageUpdateInfoSpec spec1 = new ImageUpdateInfoSpec();
|
||||||
.clearAllViewers(true)
|
spec1.setClearAllViewers(true);
|
||||||
.build();
|
|
||||||
image = this.imageService.update(image, owner, spec1);
|
image = this.imageService.update(image, owner, spec1);
|
||||||
assertThat(image.getViewers(), empty());
|
assertThat(image.getViewers(), empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.job;
|
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.IntegrationTestsExtension;
|
|
||||||
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.boot.test.context.TestConfiguration;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import static org.awaitility.Awaitility.await;
|
|
||||||
|
|
||||||
@SpringBootTest
|
|
||||||
@ExtendWith(IntegrationTestsExtension.class)
|
|
||||||
public class JobServiceIntegrationTests {
|
|
||||||
|
|
||||||
@Component
|
|
||||||
private static final class TestJobHandler implements JobHandler<Object> {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Class<Object> getPayloadType() {
|
|
||||||
return Object.class;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getJobKey() {
|
|
||||||
return "TEST_JOB_TYPE";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handle(Job job, Object payload) {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@TestConfiguration
|
|
||||||
public static class JobServiceIntegrationTestsConfiguration {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public JobHandler<Object> testJobHandler() {
|
|
||||||
return new TestJobHandler();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not mockito, it will try to load a ChatClient
|
|
||||||
@MockitoBean
|
|
||||||
private RecipeInferJobHandler recipeInferJobHandler;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private JobService jobService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private JobRepository jobRepository;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void smokeScreen() {
|
|
||||||
final Job job = this.jobService.create("TEST_JOB_TYPE", null);
|
|
||||||
await().atMost(1, TimeUnit.SECONDS).until(() -> {
|
|
||||||
final Job foundJob = this.jobRepository.findById(job.getId()).orElse(null);
|
|
||||||
if (foundJob == null) {
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
return foundJob.getState().equals(Job.State.DONE);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -7,7 +7,7 @@ import app.mealsmadeeasy.api.auth.LoginException;
|
|||||||
import app.mealsmadeeasy.api.image.Image;
|
import app.mealsmadeeasy.api.image.Image;
|
||||||
import app.mealsmadeeasy.api.image.ImageService;
|
import app.mealsmadeeasy.api.image.ImageService;
|
||||||
import app.mealsmadeeasy.api.image.S3ImageServiceTests;
|
import app.mealsmadeeasy.api.image.S3ImageServiceTests;
|
||||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
|
||||||
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
|
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
|
||||||
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
|
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
|
||||||
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
|
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
|
||||||
@ -93,15 +93,14 @@ public class RecipeControllerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Recipe createTestRecipe(User owner, boolean isPublic) {
|
private Recipe createTestRecipe(User owner, boolean isPublic) {
|
||||||
final RecipeCreateSpec spec = RecipeCreateSpec.builder()
|
final RecipeCreateSpec spec = new RecipeCreateSpec();
|
||||||
.slug(UUID.randomUUID().toString())
|
spec.setSlug(UUID.randomUUID().toString());
|
||||||
.title("Test Recipe")
|
spec.setTitle("Test Recipe");
|
||||||
.preparationTime(10)
|
spec.setPreparationTime(10);
|
||||||
.cookingTime(20)
|
spec.setCookingTime(20);
|
||||||
.totalTime(30)
|
spec.setTotalTime(30);
|
||||||
.rawText("# Hello, World!")
|
spec.setRawText("# Hello, World!");
|
||||||
.isPublic(isPublic)
|
spec.setPublic(isPublic);
|
||||||
.build();
|
|
||||||
return this.recipeService.create(owner, spec);
|
return this.recipeService.create(owner, spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,7 +117,7 @@ public class RecipeControllerTests {
|
|||||||
UUID.randomUUID() + ".svg",
|
UUID.randomUUID() + ".svg",
|
||||||
hal9000,
|
hal9000,
|
||||||
27881L,
|
27881L,
|
||||||
ImageCreateSpec.builder().build()
|
new ImageCreateInfoSpec()
|
||||||
);
|
);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
@ -147,7 +146,7 @@ public class RecipeControllerTests {
|
|||||||
.andExpect(jsonPath("$.recipe.owner.username").value(owner.getUsername()))
|
.andExpect(jsonPath("$.recipe.owner.username").value(owner.getUsername()))
|
||||||
.andExpect(jsonPath("$.recipe.starCount").value(0))
|
.andExpect(jsonPath("$.recipe.starCount").value(0))
|
||||||
.andExpect(jsonPath("$.recipe.viewerCount").value(0))
|
.andExpect(jsonPath("$.recipe.viewerCount").value(0))
|
||||||
.andExpect(jsonPath("$.recipe.public").value(true))
|
.andExpect(jsonPath("$.recipe.isPublic").value(true))
|
||||||
.andExpect(jsonPath("$.recipe.mainImage").value(nullValue()))
|
.andExpect(jsonPath("$.recipe.mainImage").value(nullValue()))
|
||||||
.andExpect(jsonPath("$.isStarred").value(nullValue()))
|
.andExpect(jsonPath("$.isStarred").value(nullValue()))
|
||||||
.andExpect(jsonPath("$.isOwner").value(nullValue()));
|
.andExpect(jsonPath("$.isOwner").value(nullValue()));
|
||||||
@ -226,14 +225,13 @@ public class RecipeControllerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String getUpdateBody() throws JsonProcessingException {
|
private String getUpdateBody() throws JsonProcessingException {
|
||||||
final RecipeUpdateSpec spec = RecipeUpdateSpec.builder()
|
final RecipeUpdateSpec spec = new RecipeUpdateSpec();
|
||||||
.title("Updated Test Recipe")
|
spec.setTitle("Updated Test Recipe");
|
||||||
.preparationTime(15)
|
spec.setPreparationTime(15);
|
||||||
.cookingTime(30)
|
spec.setCookingTime(30);
|
||||||
.totalTime(45)
|
spec.setTotalTime(45);
|
||||||
.rawText("# Hello, Updated World!")
|
spec.setRawText("# Hello, Updated World!");
|
||||||
.isPublic(true)
|
spec.setIsPublic(true);
|
||||||
.build();
|
|
||||||
return this.objectMapper.writeValueAsString(spec);
|
return this.objectMapper.writeValueAsString(spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,7 +259,7 @@ public class RecipeControllerTests {
|
|||||||
.andExpect(jsonPath("$.recipe.owner.username").value(owner.getUsername()))
|
.andExpect(jsonPath("$.recipe.owner.username").value(owner.getUsername()))
|
||||||
.andExpect(jsonPath("$.recipe.starCount").value(0))
|
.andExpect(jsonPath("$.recipe.starCount").value(0))
|
||||||
.andExpect(jsonPath("$.recipe.viewerCount").value(0))
|
.andExpect(jsonPath("$.recipe.viewerCount").value(0))
|
||||||
.andExpect(jsonPath("$.recipe.public").value(true))
|
.andExpect(jsonPath("$.recipe.isPublic").value(true))
|
||||||
.andExpect(jsonPath("$.recipe.mainImage").value(nullValue()))
|
.andExpect(jsonPath("$.recipe.mainImage").value(nullValue()))
|
||||||
.andExpect(jsonPath("$.isStarred").value(false))
|
.andExpect(jsonPath("$.isStarred").value(false))
|
||||||
.andExpect(jsonPath("$.isOwner").value(true));
|
.andExpect(jsonPath("$.isOwner").value(true));
|
||||||
@ -273,25 +271,21 @@ public class RecipeControllerTests {
|
|||||||
|
|
||||||
final Image hal9000 = this.createHal9000(owner);
|
final Image hal9000 = this.createHal9000(owner);
|
||||||
|
|
||||||
final RecipeCreateSpec createSpec = RecipeCreateSpec.builder()
|
final RecipeCreateSpec createSpec = new RecipeCreateSpec();
|
||||||
.title("Test Recipe")
|
createSpec.setTitle("Test Recipe");
|
||||||
.slug(UUID.randomUUID().toString())
|
createSpec.setSlug("test-recipe");
|
||||||
.isPublic(false)
|
createSpec.setPublic(false);
|
||||||
.rawText("# Hello, World!")
|
createSpec.setRawText("# Hello, World!");
|
||||||
.mainImage(hal9000)
|
createSpec.setMainImage(hal9000);
|
||||||
.build();
|
|
||||||
Recipe recipe = this.recipeService.create(owner, createSpec);
|
Recipe recipe = this.recipeService.create(owner, createSpec);
|
||||||
|
|
||||||
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.builder()
|
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec();
|
||||||
.title("Updated Test Recipe")
|
updateSpec.setTitle("Updated Test Recipe");
|
||||||
.rawText("# Hello, Updated World!")
|
updateSpec.setRawText("# Hello, Updated World!");
|
||||||
.mainImage(
|
final RecipeUpdateSpec.MainImageUpdateSpec mainImageUpdateSpec = new RecipeUpdateSpec.MainImageUpdateSpec();
|
||||||
RecipeUpdateSpec.MainImageUpdateSpec.builder()
|
mainImageUpdateSpec.setUsername(hal9000.getOwner().getUsername());
|
||||||
.username(hal9000.getOwner().getUsername())
|
mainImageUpdateSpec.setFilename(hal9000.getUserFilename());
|
||||||
.filename(hal9000.getUserFilename())
|
updateSpec.setMainImage(mainImageUpdateSpec);
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
final String body = this.objectMapper.writeValueAsString(updateSpec);
|
final String body = this.objectMapper.writeValueAsString(updateSpec);
|
||||||
|
|
||||||
final String accessToken = this.getAccessToken(owner);
|
final String accessToken = this.getAccessToken(owner);
|
||||||
|
|||||||
@ -24,7 +24,8 @@ import java.util.UUID;
|
|||||||
import static app.mealsmadeeasy.api.recipe.ContainsRecipeInfoViewsForRecipesMatcher.containsRecipeInfoViewsForRecipes;
|
import static app.mealsmadeeasy.api.recipe.ContainsRecipeInfoViewsForRecipesMatcher.containsRecipeInfoViewsForRecipes;
|
||||||
import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes;
|
import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes;
|
||||||
import static org.hamcrest.MatcherAssert.assertThat;
|
import static org.hamcrest.MatcherAssert.assertThat;
|
||||||
import static org.hamcrest.Matchers.*;
|
import static org.hamcrest.Matchers.is;
|
||||||
|
import static org.hamcrest.Matchers.not;
|
||||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
@ -57,12 +58,11 @@ public class RecipeServiceTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Recipe createTestRecipe(@Nullable User owner, boolean isPublic) {
|
private Recipe createTestRecipe(@Nullable User owner, boolean isPublic) {
|
||||||
final RecipeCreateSpec spec = RecipeCreateSpec.builder()
|
final RecipeCreateSpec spec = new RecipeCreateSpec();
|
||||||
.slug(UUID.randomUUID().toString())
|
spec.setSlug(UUID.randomUUID().toString());
|
||||||
.title("My Recipe")
|
spec.setTitle("My Recipe");
|
||||||
.rawText("Hello!")
|
spec.setRawText("Hello!");
|
||||||
.isPublic(isPublic)
|
spec.setPublic(isPublic);
|
||||||
.build();
|
|
||||||
return this.recipeService.create(owner, spec);
|
return this.recipeService.create(owner, spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,9 +80,7 @@ public class RecipeServiceTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void createWithoutOwnerThrowsAccessDenied() {
|
public void createWithoutOwnerThrowsAccessDenied() {
|
||||||
assertThrows(AccessDeniedException.class, () -> this.recipeService.create(
|
assertThrows(AccessDeniedException.class, () -> this.recipeService.create(null, new RecipeCreateSpec()));
|
||||||
null, RecipeCreateSpec.builder().build()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -158,9 +156,8 @@ public class RecipeServiceTests {
|
|||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
final User viewer = this.seedUser();
|
final User viewer = this.seedUser();
|
||||||
final Recipe notYetPublicRecipe = this.createTestRecipe(owner);
|
final Recipe notYetPublicRecipe = this.createTestRecipe(owner);
|
||||||
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(notYetPublicRecipe)
|
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec(notYetPublicRecipe);
|
||||||
.isPublic(true)
|
updateSpec.setIsPublic(true);
|
||||||
.build();
|
|
||||||
final Recipe publicRecipe = this.recipeService.update(
|
final Recipe publicRecipe = this.recipeService.update(
|
||||||
notYetPublicRecipe.getOwner().getUsername(),
|
notYetPublicRecipe.getOwner().getUsername(),
|
||||||
notYetPublicRecipe.getSlug(),
|
notYetPublicRecipe.getSlug(),
|
||||||
@ -287,16 +284,14 @@ public class RecipeServiceTests {
|
|||||||
@Test
|
@Test
|
||||||
public void updateRawText() throws RecipeException, ImageException {
|
public void updateRawText() throws RecipeException, ImageException {
|
||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
final RecipeCreateSpec createSpec = RecipeCreateSpec.builder()
|
final RecipeCreateSpec createSpec = new RecipeCreateSpec();
|
||||||
.slug(UUID.randomUUID().toString())
|
createSpec.setSlug("my-recipe");
|
||||||
.title("My Recipe")
|
createSpec.setTitle("My Recipe");
|
||||||
.rawText("# A Heading")
|
createSpec.setRawText("# A Heading");
|
||||||
.build();
|
|
||||||
Recipe recipe = this.recipeService.create(owner, createSpec);
|
Recipe recipe = this.recipeService.create(owner, createSpec);
|
||||||
final String newRawText = "# A Heading\n## A Subheading";
|
final String newRawText = "# A Heading\n## A Subheading";
|
||||||
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(recipe)
|
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec(recipe);
|
||||||
.rawText(newRawText)
|
updateSpec.setRawText(newRawText);
|
||||||
.build();
|
|
||||||
recipe = this.recipeService.update(
|
recipe = this.recipeService.update(
|
||||||
recipe.getOwner().getUsername(),
|
recipe.getOwner().getUsername(),
|
||||||
recipe.getSlug(),
|
recipe.getSlug(),
|
||||||
@ -311,9 +306,8 @@ public class RecipeServiceTests {
|
|||||||
final User owner = this.seedUser();
|
final User owner = this.seedUser();
|
||||||
final User notOwner = this.seedUser();
|
final User notOwner = this.seedUser();
|
||||||
final Recipe recipe = this.createTestRecipe(owner);
|
final Recipe recipe = this.createTestRecipe(owner);
|
||||||
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(recipe)
|
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec();
|
||||||
.rawText("should fail")
|
updateSpec.setRawText("should fail");
|
||||||
.build();
|
|
||||||
assertThrows(
|
assertThrows(
|
||||||
AccessDeniedException.class,
|
AccessDeniedException.class,
|
||||||
() -> this.recipeService.update(
|
() -> this.recipeService.update(
|
||||||
@ -341,13 +335,4 @@ public class RecipeServiceTests {
|
|||||||
assertThrows(AccessDeniedException.class, () -> this.recipeService.deleteRecipe(toDelete.getId(), notOwner));
|
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.recipe.job;
|
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.IntegrationTestsExtension;
|
|
||||||
import app.mealsmadeeasy.api.file.File;
|
|
||||||
import app.mealsmadeeasy.api.file.FileService;
|
|
||||||
import app.mealsmadeeasy.api.recipe.RecipeDraft;
|
|
||||||
import app.mealsmadeeasy.api.recipe.RecipeService;
|
|
||||||
import app.mealsmadeeasy.api.user.User;
|
|
||||||
import app.mealsmadeeasy.api.user.UserCreateException;
|
|
||||||
import app.mealsmadeeasy.api.user.UserService;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
|
||||||
import org.springframework.test.context.DynamicPropertySource;
|
|
||||||
import org.testcontainers.containers.MinIOContainer;
|
|
||||||
import org.testcontainers.junit.jupiter.Container;
|
|
||||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import static org.awaitility.Awaitility.await;
|
|
||||||
import static org.hamcrest.MatcherAssert.assertThat;
|
|
||||||
import static org.hamcrest.Matchers.is;
|
|
||||||
import static org.hamcrest.Matchers.notNullValue;
|
|
||||||
|
|
||||||
@SpringBootTest
|
|
||||||
@ExtendWith(IntegrationTestsExtension.class)
|
|
||||||
@Testcontainers
|
|
||||||
public class RecipeInferJobIntegrationTests {
|
|
||||||
|
|
||||||
@Container
|
|
||||||
private static final MinIOContainer minioContainer = new MinIOContainer("minio/minio:latest");
|
|
||||||
|
|
||||||
@DynamicPropertySource
|
|
||||||
public static void minioProperties(DynamicPropertyRegistry registry) {
|
|
||||||
registry.add("app.mealsmadeeasy.api.minio.endpoint", minioContainer::getS3URL);
|
|
||||||
registry.add("app.mealsmadeeasy.api.minio.accessKey", minioContainer::getUserName);
|
|
||||||
registry.add("app.mealsmadeeasy.api.minio.secretKey", minioContainer::getPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UserService userService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RecipeService recipeService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private FileService fileService;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void enqueueJobAndWait() throws UserCreateException, IOException {
|
|
||||||
final String ownerName = UUID.randomUUID().toString();
|
|
||||||
final User owner = this.userService.createUser(
|
|
||||||
ownerName,
|
|
||||||
ownerName + "@test.com",
|
|
||||||
"test-pass"
|
|
||||||
);
|
|
||||||
final File sourceFile = this.fileService.create(
|
|
||||||
RecipeInferJobIntegrationTests.class.getResourceAsStream("recipe.jpeg"),
|
|
||||||
"recipe.jpeg",
|
|
||||||
127673L,
|
|
||||||
owner
|
|
||||||
);
|
|
||||||
final RecipeDraft draft = this.recipeService.createAiDraft(sourceFile, owner);
|
|
||||||
|
|
||||||
await().atMost(60, TimeUnit.SECONDS).until(() -> {
|
|
||||||
final RecipeDraft foundDraft = this.recipeService.getDraftById(draft.getId());
|
|
||||||
return foundDraft.getState().equals(RecipeDraft.State.ENTER_DATA);
|
|
||||||
});
|
|
||||||
|
|
||||||
final RecipeDraft draftWithInference = this.recipeService.getDraftById(draft.getId());
|
|
||||||
|
|
||||||
assertThat(draftWithInference.getInferences(), is(notNullValue()));
|
|
||||||
final List<RecipeDraft.RecipeDraftInference> inferences = draftWithInference.getInferences();
|
|
||||||
|
|
||||||
assertThat(inferences.size(), is(1));
|
|
||||||
final RecipeDraft.RecipeDraftInference inference = inferences.getFirst();
|
|
||||||
assertThat(inference.getTitle(), is(notNullValue()));
|
|
||||||
assertThat(inference.getRawText(), is(notNullValue()));
|
|
||||||
assertThat(inference.getInferredAt(), is(notNullValue()));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -56,7 +56,7 @@ public class RecipeStarRepositoryTests {
|
|||||||
final RecipeStar starDraft = new RecipeStar();
|
final RecipeStar starDraft = new RecipeStar();
|
||||||
final RecipeStarId starId = new RecipeStarId();
|
final RecipeStarId starId = new RecipeStarId();
|
||||||
starId.setRecipeId(recipe.getId());
|
starId.setRecipeId(recipe.getId());
|
||||||
starId.setOwnerId(owner.getId());
|
starId.getOwnerId(owner.getId());
|
||||||
starDraft.setId(starId);
|
starDraft.setId(starId);
|
||||||
this.recipeStarRepository.save(starDraft);
|
this.recipeStarRepository.save(starDraft);
|
||||||
|
|
||||||
|
|||||||
@ -44,12 +44,11 @@ public class RecipeStarServiceTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Recipe seedRecipe(User owner) {
|
private Recipe seedRecipe(User owner) {
|
||||||
final RecipeCreateSpec spec = RecipeCreateSpec.builder()
|
final RecipeCreateSpec spec = new RecipeCreateSpec();
|
||||||
.slug(UUID.randomUUID().toString())
|
spec.setSlug(UUID.randomUUID().toString());
|
||||||
.title("Test Recipe")
|
spec.setTitle("Test Recipe");
|
||||||
.rawText("My great recipe has five ingredients.")
|
spec.setRawText("My great recipe has five ingredients.");
|
||||||
.isPublic(true)
|
spec.setPublic(true);
|
||||||
.build();
|
|
||||||
return this.recipeService.create(owner, spec);
|
return this.recipeService.create(owner, spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 125 KiB |
@ -5,7 +5,6 @@ app.mealsmadeeasy.api.minio.endpoint=http://localhost:9000
|
|||||||
app.mealsmadeeasy.api.minio.accessKey=minio-root
|
app.mealsmadeeasy.api.minio.accessKey=minio-root
|
||||||
app.mealsmadeeasy.api.minio.secretKey=test0123
|
app.mealsmadeeasy.api.minio.secretKey=test0123
|
||||||
app.mealsmadeeasy.api.images.bucketName=images
|
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
|
# 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
|
# Posted by Iogui, modified by community. See post 'Timeline' for change history
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
package app.mealsmadeeasy.api;
|
package app.mealsmadeeasy.api;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.recipe.Recipe;
|
import app.mealsmadeeasy.api.recipe.Recipe;
|
||||||
import app.mealsmadeeasy.api.recipe.RecipeEmbedding;
|
import app.mealsmadeeasy.api.recipe.RecipeEmbeddingEntity;
|
||||||
import app.mealsmadeeasy.api.recipe.RecipeRepository;
|
import app.mealsmadeeasy.api.recipe.RecipeRepository;
|
||||||
import app.mealsmadeeasy.api.recipe.RecipeService;
|
import app.mealsmadeeasy.api.recipe.RecipeService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@ -44,7 +44,7 @@ public class BackfillRecipeEmbeddings implements ApplicationRunner {
|
|||||||
final String toEmbed = "<h1>" + recipe.getTitle() + "</h1>" + renderedMarkdown;
|
final String toEmbed = "<h1>" + recipe.getTitle() + "</h1>" + renderedMarkdown;
|
||||||
final float[] embedding = this.embeddingModel.embed(toEmbed);
|
final float[] embedding = this.embeddingModel.embed(toEmbed);
|
||||||
|
|
||||||
final RecipeEmbedding recipeEmbedding = new RecipeEmbedding();
|
final RecipeEmbeddingEntity recipeEmbedding = new RecipeEmbeddingEntity();
|
||||||
recipeEmbedding.setRecipe(recipe);
|
recipeEmbedding.setRecipe(recipe);
|
||||||
recipeEmbedding.setEmbedding(embedding);
|
recipeEmbedding.setEmbedding(embedding);
|
||||||
recipeEmbedding.setTimestamp(OffsetDateTime.now());
|
recipeEmbedding.setTimestamp(OffsetDateTime.now());
|
||||||
|
|||||||
@ -2,7 +2,7 @@ package app.mealsmadeeasy.api;
|
|||||||
|
|
||||||
import app.mealsmadeeasy.api.image.Image;
|
import app.mealsmadeeasy.api.image.Image;
|
||||||
import app.mealsmadeeasy.api.image.ImageService;
|
import app.mealsmadeeasy.api.image.ImageService;
|
||||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
|
||||||
import app.mealsmadeeasy.api.recipe.Recipe;
|
import app.mealsmadeeasy.api.recipe.Recipe;
|
||||||
import app.mealsmadeeasy.api.recipe.RecipeService;
|
import app.mealsmadeeasy.api.recipe.RecipeService;
|
||||||
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
|
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
|
||||||
@ -82,11 +82,10 @@ public class DevConfiguration {
|
|||||||
rawFrontMatter, RecipeFrontMatter.class
|
rawFrontMatter, RecipeFrontMatter.class
|
||||||
);
|
);
|
||||||
|
|
||||||
final ImageCreateSpec imageCreateSpec = ImageCreateSpec.builder()
|
final ImageCreateInfoSpec imageCreateSpec = new ImageCreateInfoSpec();
|
||||||
.alt(frontMatter.mainImage.alt)
|
imageCreateSpec.setAlt(frontMatter.mainImage.alt);
|
||||||
.caption(frontMatter.mainImage.caption)
|
imageCreateSpec.setCaption(frontMatter.mainImage.caption);
|
||||||
.isPublic(frontMatter.mainImage.isPublic)
|
imageCreateSpec.setPublic(frontMatter.mainImage.isPublic);
|
||||||
.build();
|
|
||||||
final Path givenPath = Path.of(frontMatter.mainImage.src);
|
final Path givenPath = Path.of(frontMatter.mainImage.src);
|
||||||
final Path resolvedPath = Path.of("dev-data", "images").resolve(givenPath);
|
final Path resolvedPath = Path.of("dev-data", "images").resolve(givenPath);
|
||||||
final Image mainImage;
|
final Image mainImage;
|
||||||
@ -102,13 +101,12 @@ public class DevConfiguration {
|
|||||||
logger.info("Created mainImage {} for {}", mainImage, recipePath);
|
logger.info("Created mainImage {} for {}", mainImage, recipePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
final RecipeCreateSpec recipeCreateSpec = RecipeCreateSpec.builder()
|
final RecipeCreateSpec recipeCreateSpec = new RecipeCreateSpec();
|
||||||
.slug(frontMatter.slug)
|
recipeCreateSpec.setSlug(frontMatter.slug);
|
||||||
.title(frontMatter.title)
|
recipeCreateSpec.setTitle(frontMatter.title);
|
||||||
.rawText(rawRecipeText)
|
recipeCreateSpec.setRawText(rawRecipeText);
|
||||||
.isPublic(frontMatter.isPublic)
|
recipeCreateSpec.setPublic(frontMatter.isPublic);
|
||||||
.mainImage(mainImage)
|
recipeCreateSpec.setMainImage(mainImage);
|
||||||
.build();
|
|
||||||
final Recipe recipe = this.recipeService.create(testUser, recipeCreateSpec);
|
final Recipe recipe = this.recipeService.create(testUser, recipeCreateSpec);
|
||||||
logger.info("Created recipe {}", recipe);
|
logger.info("Created recipe {}", recipe);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,7 @@ public class AuthServiceImpl implements AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private RefreshToken createRefreshToken(User principal) {
|
private RefreshToken createRefreshToken(User principal) {
|
||||||
final RefreshToken refreshTokenDraft = new RefreshToken();
|
final RefreshTokenEntity refreshTokenDraft = new RefreshTokenEntity();
|
||||||
refreshTokenDraft.setToken(UUID.randomUUID());
|
refreshTokenDraft.setToken(UUID.randomUUID());
|
||||||
refreshTokenDraft.setIssued(OffsetDateTime.now());
|
refreshTokenDraft.setIssued(OffsetDateTime.now());
|
||||||
refreshTokenDraft.setExpiration(OffsetDateTime.now().plusSeconds(this.refreshTokenLifetime));
|
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.");
|
throw new LoginException(LoginExceptionReason.NO_REFRESH_TOKEN, "No refresh token provided.");
|
||||||
}
|
}
|
||||||
|
|
||||||
final RefreshToken old = this.refreshTokenRepository.findByToken(refreshToken)
|
final RefreshTokenEntity old = this.refreshTokenRepository.findByToken(refreshToken)
|
||||||
.orElseThrow(() -> new LoginException(
|
.orElseThrow(() -> new LoginException(
|
||||||
LoginExceptionReason.INVALID_REFRESH_TOKEN,
|
LoginExceptionReason.INVALID_REFRESH_TOKEN,
|
||||||
"No such refresh token: " + refreshToken
|
"No such refresh token: " + refreshToken
|
||||||
));
|
));
|
||||||
if (old.getRevoked() || old.getDeleted()) {
|
if (old.isRevoked() || old.isDeleted()) {
|
||||||
throw new LoginException(LoginExceptionReason.INVALID_REFRESH_TOKEN, "Invalid refresh token.");
|
throw new LoginException(LoginExceptionReason.INVALID_REFRESH_TOKEN, "Invalid refresh token.");
|
||||||
}
|
}
|
||||||
if (old.getExpiration().isBefore(OffsetDateTime.now())) {
|
if (old.getExpires().isBefore(OffsetDateTime.now())) {
|
||||||
throw new LoginException(LoginExceptionReason.EXPIRED_REFRESH_TOKEN, "Refresh token is expired.");
|
throw new LoginException(LoginExceptionReason.EXPIRED_REFRESH_TOKEN, "Refresh token is expired.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,24 @@
|
|||||||
package app.mealsmadeeasy.api.auth;
|
package app.mealsmadeeasy.api.auth;
|
||||||
|
|
||||||
import lombok.Data;
|
public final class LoginBody {
|
||||||
|
|
||||||
@Data
|
|
||||||
public class LoginBody {
|
|
||||||
private String username;
|
private String username;
|
||||||
private String password;
|
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,10 +1,8 @@
|
|||||||
package app.mealsmadeeasy.api.auth;
|
package app.mealsmadeeasy.api.auth;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.security.AuthToken;
|
import app.mealsmadeeasy.api.security.AuthToken;
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
@Getter
|
public final class LoginDetails {
|
||||||
public class LoginDetails {
|
|
||||||
|
|
||||||
private final String username;
|
private final String username;
|
||||||
private final AuthToken accessToken;
|
private final AuthToken accessToken;
|
||||||
@ -16,4 +14,16 @@ public class LoginDetails {
|
|||||||
this.refreshToken = refreshToken;
|
this.refreshToken = refreshToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return this.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthToken getAccessToken() {
|
||||||
|
return this.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RefreshToken getRefreshToken() {
|
||||||
|
return this.refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
package app.mealsmadeeasy.api.auth;
|
package app.mealsmadeeasy.api.auth;
|
||||||
|
|
||||||
import lombok.Getter;
|
public final class LoginException extends Exception {
|
||||||
|
|
||||||
@Getter
|
|
||||||
public class LoginException extends Exception {
|
|
||||||
|
|
||||||
private final LoginExceptionReason reason;
|
private final LoginExceptionReason reason;
|
||||||
|
|
||||||
@ -17,4 +14,8 @@ public class LoginException extends Exception {
|
|||||||
this.reason = reason;
|
this.reason = reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LoginExceptionReason getReason() {
|
||||||
|
return this.reason;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
package app.mealsmadeeasy.api.auth;
|
package app.mealsmadeeasy.api.auth;
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
public class LoginExceptionView {
|
public class LoginExceptionView {
|
||||||
|
|
||||||
private final LoginExceptionReason reason;
|
private final LoginExceptionReason reason;
|
||||||
@ -13,4 +10,12 @@ public class LoginExceptionView {
|
|||||||
this.message = message;
|
this.message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LoginExceptionReason getReason() {
|
||||||
|
return this.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return this.message;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
package app.mealsmadeeasy.api.auth;
|
package app.mealsmadeeasy.api.auth;
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Getter
|
public final class LoginView {
|
||||||
public class LoginView {
|
|
||||||
|
|
||||||
private final String username;
|
private final String username;
|
||||||
private final String accessToken;
|
private final String accessToken;
|
||||||
@ -17,4 +14,16 @@ public class LoginView {
|
|||||||
this.expires = expires;
|
this.expires = expires;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return this.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAccessToken() {
|
||||||
|
return this.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getExpires() {
|
||||||
|
return this.expires;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,40 +1,13 @@
|
|||||||
package app.mealsmadeeasy.api.auth;
|
package app.mealsmadeeasy.api.auth;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.user.User;
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.temporal.ChronoUnit;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Entity
|
public interface RefreshToken {
|
||||||
@Table(name = "refresh_token")
|
UUID getToken();
|
||||||
@Data
|
long getLifetime();
|
||||||
public class RefreshToken {
|
OffsetDateTime getExpires();
|
||||||
|
OffsetDateTime getIssued();
|
||||||
@Id
|
boolean isRevoked();
|
||||||
@Column(nullable = false)
|
boolean isDeleted();
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,92 @@
|
|||||||
|
package app.mealsmadeeasy.api.auth;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.user.User;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity(name = "RefreshToken")
|
||||||
|
@Table(name = "refresh_token")
|
||||||
|
public class RefreshTokenEntity implements RefreshToken {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(nullable = false)
|
||||||
|
private UUID token;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private OffsetDateTime issued;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private OffsetDateTime expiration;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "owner_id", nullable = false)
|
||||||
|
private User owner;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean deleted = false;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean revoked = false;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UUID getToken() {
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setToken(UUID token) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OffsetDateTime getIssued() {
|
||||||
|
return this.issued;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIssued(OffsetDateTime issued) {
|
||||||
|
this.issued = issued;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OffsetDateTime getExpires() {
|
||||||
|
return this.expiration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpiration(OffsetDateTime expiration) {
|
||||||
|
this.expiration = expiration;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRevoked() {
|
||||||
|
return this.revoked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRevoked(boolean revoked) {
|
||||||
|
this.revoked = revoked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getOwner() {
|
||||||
|
return this.owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwner(User owner) {
|
||||||
|
this.owner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isDeleted() {
|
||||||
|
return this.deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeleted(boolean deleted) {
|
||||||
|
this.deleted = deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getLifetime() {
|
||||||
|
return ChronoUnit.SECONDS.between(this.issued, this.expiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -8,9 +8,9 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, UUID> {
|
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
|
||||||
|
|
||||||
Optional<RefreshToken> findByToken(UUID token);
|
Optional<RefreshTokenEntity> findByToken(UUID token);
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.file;
|
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.user.User;
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "file")
|
|
||||||
@Data
|
|
||||||
public class File {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.UUID)
|
|
||||||
@Column(nullable = false, updatable = false)
|
|
||||||
private UUID id;
|
|
||||||
|
|
||||||
@Column(nullable = false, updatable = false)
|
|
||||||
private OffsetDateTime created;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private String userFilename;
|
|
||||||
|
|
||||||
@Column(nullable = false, updatable = false)
|
|
||||||
private String mimeType;
|
|
||||||
|
|
||||||
@Column(nullable = false, updatable = false)
|
|
||||||
private String objectName;
|
|
||||||
|
|
||||||
@ManyToOne(optional = false)
|
|
||||||
@JoinColumn(name = "owner_id", nullable = false)
|
|
||||||
private User owner;
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.file;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface FileRepository extends JpaRepository<File, UUID> {}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.file;
|
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.s3.S3Manager;
|
|
||||||
import app.mealsmadeeasy.api.user.User;
|
|
||||||
import app.mealsmadeeasy.api.util.MimeTypeService;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class FileService {
|
|
||||||
|
|
||||||
private final FileRepository fileRepository;
|
|
||||||
private final S3Manager s3Manager;
|
|
||||||
private final MimeTypeService mimeTypeService;
|
|
||||||
private final String filesBucket;
|
|
||||||
|
|
||||||
public FileService(
|
|
||||||
FileRepository fileRepository,
|
|
||||||
S3Manager s3Manager,
|
|
||||||
MimeTypeService mimeTypeService,
|
|
||||||
@Value("${app.mealsmadeeasy.api.files.bucketName}") String filesBucket
|
|
||||||
) {
|
|
||||||
this.fileRepository = fileRepository;
|
|
||||||
this.s3Manager = s3Manager;
|
|
||||||
this.mimeTypeService = mimeTypeService;
|
|
||||||
this.filesBucket = filesBucket;
|
|
||||||
}
|
|
||||||
|
|
||||||
public File create(
|
|
||||||
InputStream fileContent,
|
|
||||||
String userFilename,
|
|
||||||
long fileSize,
|
|
||||||
User owner
|
|
||||||
) throws IOException {
|
|
||||||
final UUID uuid = UUID.randomUUID();
|
|
||||||
final String mimeType = this.mimeTypeService.getMimeType(userFilename);
|
|
||||||
final String filename = uuid + "." + this.mimeTypeService.getExtension(mimeType);
|
|
||||||
|
|
||||||
final String objectName = this.s3Manager.store(
|
|
||||||
this.filesBucket,
|
|
||||||
filename,
|
|
||||||
mimeType,
|
|
||||||
fileContent,
|
|
||||||
fileSize
|
|
||||||
);
|
|
||||||
|
|
||||||
final var file = new File();
|
|
||||||
file.setCreated(OffsetDateTime.now());
|
|
||||||
file.setUserFilename(userFilename);
|
|
||||||
file.setMimeType(mimeType);
|
|
||||||
file.setObjectName(objectName);
|
|
||||||
file.setOwner(owner);
|
|
||||||
|
|
||||||
return this.fileRepository.save(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
public InputStream getFileContentById(UUID fileId) throws IOException {
|
|
||||||
final File file = this.fileRepository.findById(fileId).orElseThrow(() -> new IllegalArgumentException(
|
|
||||||
"File with id " + fileId + " not found"
|
|
||||||
));
|
|
||||||
return this.s3Manager.load(this.filesBucket, file.getObjectName());
|
|
||||||
}
|
|
||||||
|
|
||||||
public File getById(UUID id) {
|
|
||||||
return this.fileRepository.findById(id).orElseThrow(() -> new IllegalArgumentException(
|
|
||||||
"File with id " + id + " not found"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,58 +1,22 @@
|
|||||||
package app.mealsmadeeasy.api.image;
|
package app.mealsmadeeasy.api.image;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
import jakarta.persistence.*;
|
import org.jetbrains.annotations.Nullable;
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@Entity
|
public interface Image {
|
||||||
@Table(name = "image")
|
Integer getId();
|
||||||
@Data
|
OffsetDateTime getCreated();
|
||||||
public class Image {
|
@Nullable OffsetDateTime getModified();
|
||||||
|
String getUserFilename();
|
||||||
@Id
|
String getMimeType();
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@Nullable String getAlt();
|
||||||
@Column(nullable = false, updatable = false)
|
@Nullable String getCaption();
|
||||||
private Integer id;
|
User getOwner();
|
||||||
|
boolean isPublic();
|
||||||
@Column(nullable = false)
|
@Nullable Integer getHeight();
|
||||||
private OffsetDateTime created = OffsetDateTime.now();
|
@Nullable Integer getWidth();
|
||||||
|
Set<User> getViewers();
|
||||||
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;
|
package app.mealsmadeeasy.api.image;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.image.body.ImageUpdateBody;
|
import app.mealsmadeeasy.api.image.body.ImageUpdateInfoBody;
|
||||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
|
||||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
|
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
|
||||||
import app.mealsmadeeasy.api.image.view.ImageView;
|
import app.mealsmadeeasy.api.image.view.ImageView;
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
import app.mealsmadeeasy.api.user.UserService;
|
import app.mealsmadeeasy.api.user.UserService;
|
||||||
@ -34,25 +34,27 @@ public class ImageController {
|
|||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ImageUpdateSpec getImageUpdateSpec(ImageUpdateBody body) {
|
private ImageUpdateInfoSpec getImageUpdateSpec(ImageUpdateInfoBody body) {
|
||||||
final var builder = ImageUpdateSpec.builder()
|
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
|
||||||
.alt(body.getAlt())
|
spec.setAlt(body.getAlt());
|
||||||
.caption(body.getCaption())
|
spec.setCaption(body.getCaption());
|
||||||
.isPublic(body.getIsPublic())
|
spec.setPublic(body.getPublic());
|
||||||
.clearAllViewers(body.getClearAllViewers());
|
|
||||||
if (body.getViewersToAdd() != null) {
|
if (body.getViewersToAdd() != null) {
|
||||||
builder.viewersToAdd(body.getViewersToAdd().stream()
|
spec.setViewersToAdd(
|
||||||
.map(this.userService::getUser)
|
body.getViewersToAdd().stream()
|
||||||
.collect(Collectors.toSet())
|
.map(this.userService::getUser)
|
||||||
|
.collect(Collectors.toSet())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (body.getViewersToRemove() != null) {
|
if (body.getViewersToRemove() != null) {
|
||||||
builder.viewersToRemove(body.getViewersToRemove().stream()
|
spec.setViewersToRemove(
|
||||||
.map(this.userService::getUser)
|
body.getViewersToRemove().stream()
|
||||||
.collect(Collectors.toSet())
|
.map(this.userService::getUser)
|
||||||
|
.collect(Collectors.toSet())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return builder.build();
|
spec.setClearAllViewers(body.getClearAllViewers());
|
||||||
|
return spec;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler
|
@ExceptionHandler
|
||||||
@ -95,20 +97,19 @@ public class ImageController {
|
|||||||
if (principal == null) {
|
if (principal == null) {
|
||||||
throw new AccessDeniedException("Must be logged in.");
|
throw new AccessDeniedException("Must be logged in.");
|
||||||
}
|
}
|
||||||
final var specBuilder = ImageCreateSpec.builder()
|
final ImageCreateInfoSpec createSpec = new ImageCreateInfoSpec();
|
||||||
.alt(alt)
|
createSpec.setAlt(alt);
|
||||||
.caption(caption)
|
createSpec.setCaption(caption);
|
||||||
.isPublic(isPublic);
|
createSpec.setPublic(isPublic);
|
||||||
|
|
||||||
if (viewers != null) {
|
if (viewers != null) {
|
||||||
specBuilder.viewersToAdd(viewers.stream().map(this.userService::getUser).collect(Collectors.toSet()));
|
createSpec.setViewersToAdd(viewers.stream().map(this.userService::getUser).collect(Collectors.toSet()));
|
||||||
}
|
}
|
||||||
final Image saved = this.imageService.create(
|
final Image saved = this.imageService.create(
|
||||||
principal,
|
principal,
|
||||||
filename,
|
filename,
|
||||||
image.getInputStream(),
|
image.getInputStream(),
|
||||||
image.getSize(),
|
image.getSize(),
|
||||||
specBuilder.build()
|
createSpec
|
||||||
);
|
);
|
||||||
return ResponseEntity.status(201).body(this.imageService.toImageView(saved, principal));
|
return ResponseEntity.status(201).body(this.imageService.toImageView(saved, principal));
|
||||||
}
|
}
|
||||||
@ -118,7 +119,7 @@ public class ImageController {
|
|||||||
@AuthenticationPrincipal User principal,
|
@AuthenticationPrincipal User principal,
|
||||||
@PathVariable String username,
|
@PathVariable String username,
|
||||||
@PathVariable String filename,
|
@PathVariable String filename,
|
||||||
@RequestBody ImageUpdateBody body
|
@RequestBody ImageUpdateInfoBody body
|
||||||
) throws ImageException {
|
) throws ImageException {
|
||||||
if (principal == null) {
|
if (principal == null) {
|
||||||
throw new AccessDeniedException("Must be logged in.");
|
throw new AccessDeniedException("Must be logged in.");
|
||||||
|
|||||||
@ -9,15 +9,15 @@ import java.util.Objects;
|
|||||||
@Component("imageSecurity")
|
@Component("imageSecurity")
|
||||||
public class ImageSecurityImpl implements ImageSecurity {
|
public class ImageSecurityImpl implements ImageSecurity {
|
||||||
|
|
||||||
private final ImageRepository imageRepository;
|
private final S3ImageRepository imageRepository;
|
||||||
|
|
||||||
public ImageSecurityImpl(ImageRepository imageRepository) {
|
public ImageSecurityImpl(S3ImageRepository imageRepository) {
|
||||||
this.imageRepository = imageRepository;
|
this.imageRepository = imageRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean isViewableBy(Image image, @Nullable User viewer) {
|
public boolean isViewableBy(Image image, @Nullable User viewer) {
|
||||||
if (image.getIsPublic()) {
|
if (image.isPublic()) {
|
||||||
// public image
|
// public image
|
||||||
return true;
|
return true;
|
||||||
} else if (viewer == null) {
|
} else if (viewer == null) {
|
||||||
@ -28,7 +28,7 @@ public class ImageSecurityImpl implements ImageSecurity {
|
|||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
// check if viewer
|
// check if viewer
|
||||||
final Image withViewers = this.imageRepository.getByIdWithViewers(image.getId());
|
final S3ImageEntity withViewers = this.imageRepository.getByIdWithViewers(image.getId());
|
||||||
for (final User user : withViewers.getViewers()) {
|
for (final User user : withViewers.getViewers()) {
|
||||||
if (user.getId() != null && user.getId().equals(viewer.getId())) {
|
if (user.getId() != null && user.getId().equals(viewer.getId())) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
package app.mealsmadeeasy.api.image;
|
package app.mealsmadeeasy.api.image;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
|
||||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
|
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
|
||||||
import app.mealsmadeeasy.api.image.view.ImageView;
|
import app.mealsmadeeasy.api.image.view.ImageView;
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
@ -12,17 +12,17 @@ import java.util.List;
|
|||||||
|
|
||||||
public interface ImageService {
|
public interface ImageService {
|
||||||
|
|
||||||
Image create(User owner, String userFilename, InputStream inputStream, long objectSize, ImageCreateSpec infoSpec)
|
Image create(User owner, String userFilename, InputStream inputStream, long objectSize, ImageCreateInfoSpec infoSpec)
|
||||||
throws IOException, ImageException;
|
throws IOException, ImageException;
|
||||||
|
|
||||||
Image getById(Integer id, @Nullable User viewer) throws ImageException;
|
Image getById(long id, @Nullable User viewer) throws ImageException;
|
||||||
Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException;
|
Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException;
|
||||||
Image getByUsernameAndFilename(String username, 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;
|
InputStream getImageContent(Image image, @Nullable User viewer) throws IOException;
|
||||||
List<Image> getImagesOwnedBy(User user);
|
List<Image> getImagesOwnedBy(User user);
|
||||||
|
|
||||||
Image update(Image image, User modifier, ImageUpdateSpec spec);
|
Image update(Image image, User modifier, ImageUpdateInfoSpec spec);
|
||||||
|
|
||||||
void deleteImage(Image image, User modifier) throws IOException;
|
void deleteImage(Image image, User modifier) throws IOException;
|
||||||
|
|
||||||
|
|||||||
182
src/main/java/app/mealsmadeeasy/api/image/S3ImageEntity.java
Normal file
182
src/main/java/app/mealsmadeeasy/api/image/S3ImageEntity.java
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
package app.mealsmadeeasy.api.image;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.user.User;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Entity(name = "Image")
|
||||||
|
@Table(name = "image")
|
||||||
|
public class S3ImageEntity implements Image {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private OffsetDateTime created = OffsetDateTime.now();
|
||||||
|
|
||||||
|
private OffsetDateTime modified;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String userFilename;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String mimeType;
|
||||||
|
|
||||||
|
private String alt;
|
||||||
|
|
||||||
|
private String caption;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String objectName;
|
||||||
|
|
||||||
|
private Integer height;
|
||||||
|
|
||||||
|
private Integer width;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "owner_id", nullable = false)
|
||||||
|
private User owner;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean isPublic = false;
|
||||||
|
|
||||||
|
@ManyToMany
|
||||||
|
@JoinTable(
|
||||||
|
name = "image_viewer",
|
||||||
|
joinColumns = @JoinColumn(name = "image_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "viewer_id")
|
||||||
|
)
|
||||||
|
private Set<User> viewers = new HashSet<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getId() {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Integer id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OffsetDateTime getCreated() {
|
||||||
|
return this.created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreated(OffsetDateTime created) {
|
||||||
|
this.created = created;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable OffsetDateTime getModified() {
|
||||||
|
return this.modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setModified(OffsetDateTime modified) {
|
||||||
|
this.modified = modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUserFilename() {
|
||||||
|
return this.userFilename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserFilename(String userFilename) {
|
||||||
|
this.userFilename = userFilename;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getMimeType() {
|
||||||
|
return this.mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMimeType(String mimeType) {
|
||||||
|
this.mimeType = mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable String getAlt() {
|
||||||
|
return this.alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAlt(String alt) {
|
||||||
|
this.alt = alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable String getCaption() {
|
||||||
|
return this.caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCaption(String caption) {
|
||||||
|
this.caption = caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getObjectName() {
|
||||||
|
return this.objectName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setObjectName(String objectName) {
|
||||||
|
this.objectName = objectName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable Integer getHeight() {
|
||||||
|
return this.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHeight(Integer height) {
|
||||||
|
this.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable Integer getWidth() {
|
||||||
|
return this.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWidth(Integer width) {
|
||||||
|
this.width = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public User getOwner() {
|
||||||
|
return this.owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwner(User owner) {
|
||||||
|
this.owner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isPublic() {
|
||||||
|
return this.isPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPublic(Boolean aPublic) {
|
||||||
|
isPublic = aPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<User> getViewers() {
|
||||||
|
return Set.copyOf(this.viewers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<User> getViewerEntities() {
|
||||||
|
return this.viewers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setViewers(Set<User> viewers) {
|
||||||
|
this.viewers = viewers;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "S3ImageEntity(" + this.id + ", " + this.userFilename + ", " + this.objectName + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -8,16 +8,16 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface ImageRepository extends JpaRepository<Image, Integer> {
|
public interface S3ImageRepository extends JpaRepository<S3ImageEntity, Long> {
|
||||||
|
|
||||||
@Query("SELECT image FROM Image image WHERE image.id = ?1")
|
@Query("SELECT image FROM Image image WHERE image.id = ?1")
|
||||||
@EntityGraph(attributePaths = { "viewers" })
|
@EntityGraph(attributePaths = { "viewers" })
|
||||||
Image getByIdWithViewers(Integer id);
|
S3ImageEntity getByIdWithViewers(long id);
|
||||||
|
|
||||||
List<Image> findAllByOwner(User owner);
|
List<S3ImageEntity> findAllByOwner(User owner);
|
||||||
Optional<Image> findByOwnerAndUserFilename(User owner, String filename);
|
Optional<S3ImageEntity> findByOwnerAndUserFilename(User owner, String filename);
|
||||||
|
|
||||||
@Query("SELECT image from Image image WHERE image.owner.username = ?1 AND image.userFilename = ?2")
|
@Query("SELECT image from Image image WHERE image.owner.username = ?1 AND image.userFilename = ?2")
|
||||||
Optional<Image> findByOwnerUsernameAndFilename(String username, String filename);
|
Optional<S3ImageEntity> findByOwnerUsernameAndFilename(String username, String filename);
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -1,11 +1,10 @@
|
|||||||
package app.mealsmadeeasy.api.image;
|
package app.mealsmadeeasy.api.image;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
|
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
|
||||||
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
|
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
|
||||||
import app.mealsmadeeasy.api.image.view.ImageView;
|
import app.mealsmadeeasy.api.image.view.ImageView;
|
||||||
import app.mealsmadeeasy.api.s3.S3Manager;
|
import app.mealsmadeeasy.api.s3.S3Manager;
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
import app.mealsmadeeasy.api.util.MimeTypeService;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.access.prepost.PostAuthorize;
|
import org.springframework.security.access.prepost.PostAuthorize;
|
||||||
@ -20,31 +19,66 @@ import java.io.IOException;
|
|||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class S3ImageService implements ImageService {
|
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 S3Manager s3Manager;
|
||||||
private final ImageRepository imageRepository;
|
private final S3ImageRepository imageRepository;
|
||||||
private final String imageBucketName;
|
private final String imageBucketName;
|
||||||
private final String baseUrl;
|
private final String baseUrl;
|
||||||
private final MimeTypeService mimeTypeService;
|
|
||||||
|
|
||||||
public S3ImageService(
|
public S3ImageService(
|
||||||
S3Manager s3Manager,
|
S3Manager s3Manager,
|
||||||
ImageRepository imageRepository,
|
S3ImageRepository imageRepository,
|
||||||
@Value("${app.mealsmadeeasy.api.images.bucketName}") String imageBucketName,
|
@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.s3Manager = s3Manager;
|
||||||
this.imageRepository = imageRepository;
|
this.imageRepository = imageRepository;
|
||||||
this.imageBucketName = imageBucketName;
|
this.imageBucketName = imageBucketName;
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.mimeTypeService = mimeTypeService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean transferFromCreateSpec(Image entity, ImageCreateSpec spec) {
|
private String getMimeType(String userFilename) {
|
||||||
|
final Matcher m = extensionPattern.matcher(userFilename);
|
||||||
|
if (m.matches()) {
|
||||||
|
final String extension = m.group(1);
|
||||||
|
return switch (extension) {
|
||||||
|
case "jpg", "jpeg" -> IMAGE_JPEG;
|
||||||
|
case "png" -> IMAGE_PNG;
|
||||||
|
case "svg" -> IMAGE_SVG;
|
||||||
|
case "webp" -> IMAGE_WEBP;
|
||||||
|
default -> throw new IllegalArgumentException("Cannot determine mime type for extension: " + extension);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("Cannot determine mime type for filename: " + userFilename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getExtension(String mimeType) throws ImageException {
|
||||||
|
return switch (mimeType) {
|
||||||
|
case IMAGE_JPEG -> "jpg";
|
||||||
|
case IMAGE_PNG -> "png";
|
||||||
|
case IMAGE_SVG -> "svg";
|
||||||
|
case IMAGE_WEBP -> "webp";
|
||||||
|
default -> throw new ImageException(
|
||||||
|
ImageException.Type.UNSUPPORTED_IMAGE_TYPE,
|
||||||
|
"Unsupported mime type: " + mimeType
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean transferFromSpec(S3ImageEntity entity, ImageCreateInfoSpec spec) {
|
||||||
boolean didTransfer = false;
|
boolean didTransfer = false;
|
||||||
if (spec.getAlt() != null) {
|
if (spec.getAlt() != null) {
|
||||||
entity.setAlt(spec.getAlt());
|
entity.setAlt(spec.getAlt());
|
||||||
@ -54,55 +88,22 @@ public class S3ImageService implements ImageService {
|
|||||||
entity.setCaption(spec.getCaption());
|
entity.setCaption(spec.getCaption());
|
||||||
didTransfer = true;
|
didTransfer = true;
|
||||||
}
|
}
|
||||||
if (spec.getIsPublic() != null) {
|
if (spec.getPublic() != null) {
|
||||||
entity.setIsPublic(spec.getIsPublic());
|
entity.setPublic(spec.getPublic());
|
||||||
didTransfer = true;
|
didTransfer = true;
|
||||||
}
|
}
|
||||||
final @Nullable Set<User> viewersToAdd = spec.getViewersToAdd();
|
final @Nullable Set<User> viewersToAdd = spec.getViewersToAdd();
|
||||||
if (viewersToAdd != null) {
|
if (viewersToAdd != null) {
|
||||||
final Set<User> viewers = new HashSet<>(entity.getViewers());
|
final Set<User> viewers = new HashSet<>(entity.getViewerEntities());
|
||||||
viewers.addAll(spec.getViewersToAdd());
|
for (final User viewerToAdd : spec.getViewersToAdd()) {
|
||||||
|
viewers.add((User) viewerToAdd);
|
||||||
|
}
|
||||||
entity.setViewers(viewers);
|
entity.setViewers(viewers);
|
||||||
didTransfer = true;
|
didTransfer = true;
|
||||||
}
|
}
|
||||||
return didTransfer;
|
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}.
|
* @apiNote Consumes and closes the {@link java.io.InputStream}.
|
||||||
*
|
*
|
||||||
@ -121,11 +122,11 @@ public class S3ImageService implements ImageService {
|
|||||||
String userFilename,
|
String userFilename,
|
||||||
InputStream inputStream,
|
InputStream inputStream,
|
||||||
long objectSize,
|
long objectSize,
|
||||||
ImageCreateSpec createSpec
|
ImageCreateInfoSpec createSpec
|
||||||
) throws IOException, ImageException {
|
) throws IOException, ImageException {
|
||||||
final String mimeType = this.mimeTypeService.getMimeType(userFilename);
|
final String mimeType = this.getMimeType(userFilename);
|
||||||
final String uuid = UUID.randomUUID().toString();
|
final String uuid = UUID.randomUUID().toString();
|
||||||
final String extension = this.mimeTypeService.getExtension(mimeType);
|
final String extension = this.getExtension(mimeType);
|
||||||
final String filename = uuid + "." + extension;
|
final String filename = uuid + "." + extension;
|
||||||
|
|
||||||
final var baos = new ByteArrayOutputStream();
|
final var baos = new ByteArrayOutputStream();
|
||||||
@ -151,20 +152,20 @@ public class S3ImageService implements ImageService {
|
|||||||
toStore.close();
|
toStore.close();
|
||||||
inputStream.close();
|
inputStream.close();
|
||||||
|
|
||||||
final Image draft = new Image();
|
final S3ImageEntity draft = new S3ImageEntity();
|
||||||
draft.setOwner(owner);
|
draft.setOwner((User) owner);
|
||||||
draft.setUserFilename(userFilename);
|
draft.setUserFilename(userFilename);
|
||||||
draft.setMimeType(mimeType);
|
draft.setMimeType(mimeType);
|
||||||
draft.setObjectName(objectName);
|
draft.setObjectName(objectName);
|
||||||
draft.setHeight(height);
|
draft.setHeight(height);
|
||||||
draft.setWidth(width);
|
draft.setWidth(width);
|
||||||
this.transferFromCreateSpec(draft, createSpec);
|
this.transferFromSpec(draft, createSpec);
|
||||||
return this.imageRepository.save(draft);
|
return this.imageRepository.save(draft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)")
|
@PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)")
|
||||||
public Image getById(Integer id, @Nullable User viewer) throws ImageException {
|
public Image getById(long id, @Nullable User viewer) throws ImageException {
|
||||||
return this.imageRepository.findById(id).orElseThrow(() -> new ImageException(
|
return this.imageRepository.findById(id).orElseThrow(() -> new ImageException(
|
||||||
ImageException.Type.INVALID_ID, "No Image with id: " + id
|
ImageException.Type.INVALID_ID, "No Image with id: " + id
|
||||||
));
|
));
|
||||||
@ -194,7 +195,7 @@ public class S3ImageService implements ImageService {
|
|||||||
@Override
|
@Override
|
||||||
@PreAuthorize("@imageSecurity.isViewableBy(#image, #viewer)")
|
@PreAuthorize("@imageSecurity.isViewableBy(#image, #viewer)")
|
||||||
public InputStream getImageContent(Image image, User viewer) throws IOException {
|
public InputStream getImageContent(Image image, User viewer) throws IOException {
|
||||||
return this.s3Manager.load(this.imageBucketName, ((Image) image).getObjectName());
|
return this.s3Manager.load(this.imageBucketName, ((S3ImageEntity) image).getObjectName());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -204,19 +205,36 @@ public class S3ImageService implements ImageService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
@PreAuthorize("@imageSecurity.isOwner(#image, #modifier)")
|
@PreAuthorize("@imageSecurity.isOwner(#image, #modifier)")
|
||||||
public Image update(final Image image, User modifier, ImageUpdateSpec updateSpec) {
|
public Image update(final Image image, User modifier, ImageUpdateInfoSpec updateSpec) {
|
||||||
final boolean didUpdate = this.transferFromUpdateSpec(image, updateSpec);
|
S3ImageEntity entity = (S3ImageEntity) image;
|
||||||
if (didUpdate) {
|
boolean didUpdate = this.transferFromSpec(entity, updateSpec);
|
||||||
image.setModified(OffsetDateTime.now());
|
final @Nullable Boolean clearAllViewers = updateSpec.getClearAllViewers();
|
||||||
|
if (clearAllViewers != null && clearAllViewers) {
|
||||||
|
entity.setViewers(new HashSet<>());
|
||||||
|
didUpdate = true;
|
||||||
|
} else {
|
||||||
|
final @Nullable Set<User> viewersToRemove = updateSpec.getViewersToRemove();
|
||||||
|
if (viewersToRemove != null) {
|
||||||
|
final Set<User> currentViewers = new HashSet<>(entity.getViewerEntities());
|
||||||
|
for (final User toRemove : updateSpec.getViewersToRemove()) {
|
||||||
|
currentViewers.remove((User) toRemove);
|
||||||
|
}
|
||||||
|
entity.setViewers(currentViewers);
|
||||||
|
didUpdate = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return this.imageRepository.save(image);
|
if (didUpdate) {
|
||||||
|
entity.setModified(OffsetDateTime.now());
|
||||||
|
}
|
||||||
|
return this.imageRepository.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@PreAuthorize("@imageSecurity.isOwner(#image, #modifier)")
|
@PreAuthorize("@imageSecurity.isOwner(#image, #modifier)")
|
||||||
public void deleteImage(Image image, User modifier) throws IOException {
|
public void deleteImage(Image image, User modifier) throws IOException {
|
||||||
this.imageRepository.delete(image);
|
final S3ImageEntity imageEntity = (S3ImageEntity) image;
|
||||||
this.s3Manager.delete("images", image.getObjectName());
|
this.imageRepository.delete(imageEntity);
|
||||||
|
this.s3Manager.delete("images", imageEntity.getObjectName());
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getImageUrl(Image image) {
|
private String getImageUrl(Image image) {
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.image.body;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class ImageUpdateBody {
|
|
||||||
private @Nullable String alt;
|
|
||||||
private @Nullable String caption;
|
|
||||||
private @Nullable Boolean isPublic;
|
|
||||||
private @Nullable Set<String> viewersToAdd;
|
|
||||||
private @Nullable Set<String> viewersToRemove;
|
|
||||||
private @Nullable Boolean clearAllViewers;
|
|
||||||
}
|
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package app.mealsmadeeasy.api.image.body;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class ImageUpdateInfoBody {
|
||||||
|
|
||||||
|
private @Nullable String alt;
|
||||||
|
private @Nullable String caption;
|
||||||
|
private @Nullable Boolean isPublic;
|
||||||
|
private @Nullable Set<String> viewersToAdd;
|
||||||
|
private @Nullable Set<String> viewersToRemove;
|
||||||
|
private @Nullable Boolean clearAllViewers;
|
||||||
|
|
||||||
|
public @Nullable String getAlt() {
|
||||||
|
return this.alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAlt(@Nullable String alt) {
|
||||||
|
this.alt = alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable String getCaption() {
|
||||||
|
return this.caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCaption(@Nullable String caption) {
|
||||||
|
this.caption = caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable Boolean getPublic() {
|
||||||
|
return this.isPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPublic(@Nullable Boolean aPublic) {
|
||||||
|
isPublic = aPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable Set<String> getViewersToAdd() {
|
||||||
|
return this.viewersToAdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setViewersToAdd(@Nullable Set<String> viewersToAdd) {
|
||||||
|
this.viewersToAdd = viewersToAdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable Set<String> getViewersToRemove() {
|
||||||
|
return this.viewersToRemove;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setViewersToRemove(@Nullable Set<String> viewersToRemove) {
|
||||||
|
this.viewersToRemove = viewersToRemove;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable Boolean getClearAllViewers() {
|
||||||
|
return this.clearAllViewers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setClearAllViewers(@Nullable Boolean clearAllViewers) {
|
||||||
|
this.clearAllViewers = clearAllViewers;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package app.mealsmadeeasy.api.image.spec;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.user.User;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class ImageCreateInfoSpec {
|
||||||
|
|
||||||
|
private @Nullable String alt;
|
||||||
|
private @Nullable String caption;
|
||||||
|
private @Nullable Boolean isPublic;
|
||||||
|
private @Nullable Set<User> viewersToAdd = new HashSet<>();
|
||||||
|
|
||||||
|
public @Nullable String getAlt() {
|
||||||
|
return this.alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAlt(@Nullable String alt) {
|
||||||
|
this.alt = alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable String getCaption() {
|
||||||
|
return this.caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCaption(@Nullable String caption) {
|
||||||
|
this.caption = caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable Boolean getPublic() {
|
||||||
|
return this.isPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPublic(@Nullable Boolean aPublic) {
|
||||||
|
isPublic = aPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable Set<User> getViewersToAdd() {
|
||||||
|
return this.viewersToAdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setViewersToAdd(@Nullable Set<User> viewersToAdd) {
|
||||||
|
this.viewersToAdd = viewersToAdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,17 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.image.spec;
|
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.user.User;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
@Value
|
|
||||||
@Builder
|
|
||||||
public class ImageCreateSpec {
|
|
||||||
@Nullable String alt;
|
|
||||||
@Nullable String caption;
|
|
||||||
@Nullable Boolean isPublic;
|
|
||||||
@Nullable Set<User> viewersToAdd;
|
|
||||||
}
|
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package app.mealsmadeeasy.api.image.spec;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.user.User;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class ImageUpdateInfoSpec extends ImageCreateInfoSpec {
|
||||||
|
|
||||||
|
private @Nullable Set<User> viewersToRemove;
|
||||||
|
private @Nullable Boolean clearAllViewers;
|
||||||
|
|
||||||
|
public @Nullable Set<User> getViewersToRemove() {
|
||||||
|
return this.viewersToRemove;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setViewersToRemove(@Nullable Set<User> viewersToRemove) {
|
||||||
|
this.viewersToRemove = viewersToRemove;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable Boolean getClearAllViewers() {
|
||||||
|
return this.clearAllViewers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setClearAllViewers(@Nullable Boolean clearAllViewers) {
|
||||||
|
this.clearAllViewers = clearAllViewers;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,19 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.image.spec;
|
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.user.User;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
@Value
|
|
||||||
@Builder
|
|
||||||
public class ImageUpdateSpec {
|
|
||||||
@Nullable String alt;
|
|
||||||
@Nullable String caption;
|
|
||||||
@Nullable Boolean isPublic;
|
|
||||||
@Nullable Set<User> viewersToAdd;
|
|
||||||
@Nullable Set<User> viewersToRemove;
|
|
||||||
@Nullable Boolean clearAllViewers;
|
|
||||||
}
|
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package app.mealsmadeeasy.api.image.view;
|
||||||
|
|
||||||
|
public class ImageExceptionView {
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@ -2,52 +2,143 @@ package app.mealsmadeeasy.api.image.view;
|
|||||||
|
|
||||||
import app.mealsmadeeasy.api.image.Image;
|
import app.mealsmadeeasy.api.image.Image;
|
||||||
import app.mealsmadeeasy.api.user.view.UserInfoView;
|
import app.mealsmadeeasy.api.user.view.UserInfoView;
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Value
|
|
||||||
@Builder
|
|
||||||
public class ImageView {
|
public class ImageView {
|
||||||
|
|
||||||
public static ImageView from(Image image, String url, boolean includeViewers) {
|
public static ImageView from(Image image, String url, boolean includeViewers) {
|
||||||
final var builder = ImageView.builder()
|
final ImageView view = new ImageView();
|
||||||
.url(url)
|
view.setUrl(url);
|
||||||
.created(image.getCreated())
|
view.setCreated(image.getCreated());
|
||||||
.modified(image.getModified())
|
view.setModified(image.getModified());
|
||||||
.filename(image.getUserFilename())
|
view.setFilename(image.getUserFilename());
|
||||||
.mimeType(image.getMimeType())
|
view.setMimeType(image.getMimeType());
|
||||||
.alt(image.getAlt())
|
view.setAlt(image.getAlt());
|
||||||
.caption(image.getCaption())
|
view.setCaption(image.getCaption());
|
||||||
.owner(UserInfoView.from(image.getOwner()))
|
view.setOwner(UserInfoView.from(image.getOwner()));
|
||||||
.isPublic(image.getIsPublic())
|
view.setIsPublic(image.isPublic());
|
||||||
.height(image.getHeight())
|
view.setHeight(image.getHeight());
|
||||||
.width(image.getWidth());
|
view.setWidth(image.getWidth());
|
||||||
if (includeViewers) {
|
if (includeViewers) {
|
||||||
builder.viewers(
|
view.setViewers(image.getViewers().stream()
|
||||||
image.getViewers().stream()
|
.map(UserInfoView::from)
|
||||||
.map(UserInfoView::from)
|
.collect(Collectors.toSet())
|
||||||
.collect(Collectors.toSet())
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return builder.build();
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
String url;
|
private String url;
|
||||||
OffsetDateTime created;
|
private OffsetDateTime created;
|
||||||
@Nullable OffsetDateTime modified;
|
private @Nullable OffsetDateTime modified;
|
||||||
String filename;
|
private String filename;
|
||||||
String mimeType;
|
private String mimeType;
|
||||||
@Nullable String alt;
|
private @Nullable String alt;
|
||||||
@Nullable String caption;
|
private @Nullable String caption;
|
||||||
UserInfoView owner;
|
private UserInfoView owner;
|
||||||
boolean isPublic;
|
private boolean isPublic;
|
||||||
@Nullable Integer height;
|
private @Nullable Integer height;
|
||||||
@Nullable Integer width;
|
private @Nullable Integer width;
|
||||||
@Nullable Set<UserInfoView> viewers;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.job;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Data;
|
|
||||||
import org.hibernate.annotations.Type;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "job")
|
|
||||||
@Data
|
|
||||||
public class Job {
|
|
||||||
|
|
||||||
public enum State {
|
|
||||||
QUEUED, RUNNING, DONE, FAILED, DEAD
|
|
||||||
}
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.UUID)
|
|
||||||
@Column(nullable = false, updatable = false)
|
|
||||||
private UUID id;
|
|
||||||
|
|
||||||
@Column(nullable = false, updatable = false)
|
|
||||||
private OffsetDateTime created;
|
|
||||||
|
|
||||||
private OffsetDateTime modified;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
private State state;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private String jobKey;
|
|
||||||
|
|
||||||
@Type(JsonBinaryType.class)
|
|
||||||
@Column(nullable = false, columnDefinition = "jsonb")
|
|
||||||
private JsonNode payload;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private Integer attempts;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private Integer maxAttempts;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private OffsetDateTime runAfter;
|
|
||||||
|
|
||||||
private String lockedBy;
|
|
||||||
|
|
||||||
private OffsetDateTime lockedAt;
|
|
||||||
|
|
||||||
@Lob
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
@Basic(fetch = FetchType.LAZY)
|
|
||||||
private String lastError;
|
|
||||||
|
|
||||||
}
|
|
||||||
21
src/main/java/app/mealsmadeeasy/api/job/JobEntity.java
Normal file
21
src/main/java/app/mealsmadeeasy/api/job/JobEntity.java
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package app.mealsmadeeasy.api.job;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
|
||||||
|
public class JobEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String key;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "jsonb")
|
||||||
|
private String payload;
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,7 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.job;
|
|
||||||
|
|
||||||
public interface JobHandler<T> {
|
|
||||||
Class<T> getPayloadType();
|
|
||||||
String getJobKey();
|
|
||||||
void handle(Job job, T payload);
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.job;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface JobRepository extends JpaRepository<Job, UUID> {
|
|
||||||
|
|
||||||
@Query(value = """
|
|
||||||
WITH cte AS (
|
|
||||||
SELECT id FROM job WHERE state = 'QUEUED' AND run_after <= now() ORDER BY run_after, created
|
|
||||||
FOR UPDATE SKIP LOCKED LIMIT 1
|
|
||||||
)
|
|
||||||
UPDATE job j
|
|
||||||
SET state = 'RUNNING',
|
|
||||||
locked_by = ?1,
|
|
||||||
locked_at = now(),
|
|
||||||
modified = now()
|
|
||||||
FROM cte
|
|
||||||
WHERE j.id = cte.id
|
|
||||||
RETURNING j.*
|
|
||||||
""", nativeQuery = true)
|
|
||||||
Optional<Job> claimNext(String lockedBy);
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.job;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import jakarta.transaction.Transactional;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
|
||||||
import org.springframework.context.ApplicationContext;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.io.PrintWriter;
|
|
||||||
import java.io.StringWriter;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class JobService {
|
|
||||||
|
|
||||||
private final ApplicationContext applicationContext;
|
|
||||||
private final JobRepository jobRepository;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@SuppressWarnings("rawtypes")
|
|
||||||
private Map<String, JobHandler> jobHandlers;
|
|
||||||
|
|
||||||
public JobService(ApplicationContext applicationContext, JobRepository jobRepository, ObjectMapper objectMapper) {
|
|
||||||
this.applicationContext = applicationContext;
|
|
||||||
this.jobRepository = jobRepository;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Job create(String jobType, @Nullable Object payload) {
|
|
||||||
return this.create(jobType, payload, 10, OffsetDateTime.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Job create(String jobType, @Nullable Object payload, int maxAttempts, OffsetDateTime runAfter) {
|
|
||||||
final var job = new Job();
|
|
||||||
job.setCreated(OffsetDateTime.now());
|
|
||||||
job.setState(Job.State.QUEUED);
|
|
||||||
job.setJobKey(jobType);
|
|
||||||
job.setPayload(this.objectMapper.convertValue(payload, JsonNode.class));
|
|
||||||
job.setAttempts(0);
|
|
||||||
job.setMaxAttempts(maxAttempts);
|
|
||||||
job.setRunAfter(runAfter);
|
|
||||||
return this.jobRepository.save(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("rawtypes")
|
|
||||||
private void initJobHandlers() {
|
|
||||||
final Map<String, JobHandler> handlersByBeanName = this.applicationContext.getBeansOfType(JobHandler.class);
|
|
||||||
this.jobHandlers = new HashMap<>(handlersByBeanName);
|
|
||||||
for (final var jobHandler : handlersByBeanName.values()) {
|
|
||||||
this.jobHandlers.put(jobHandler.getJobKey(), jobHandler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked,rawtypes")
|
|
||||||
@Scheduled(fixedDelay = 200)
|
|
||||||
@Transactional
|
|
||||||
public void runOneJob() {
|
|
||||||
final Optional<Job> nextJob = this.jobRepository.claimNext(Thread.currentThread().getName());
|
|
||||||
if (nextJob.isPresent()) {
|
|
||||||
if (this.jobHandlers == null) {
|
|
||||||
this.initJobHandlers();
|
|
||||||
}
|
|
||||||
final Job job = nextJob.get();
|
|
||||||
final JobHandler jobHandler = this.jobHandlers.get(job.getJobKey());
|
|
||||||
if (jobHandler == null) {
|
|
||||||
throw new RuntimeException("There is no registered job handler for " + job.getJobKey());
|
|
||||||
}
|
|
||||||
final Object payload = this.objectMapper.convertValue(
|
|
||||||
job.getPayload(),
|
|
||||||
jobHandler.getPayloadType()
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
jobHandler.handle(job, payload);
|
|
||||||
job.setState(Job.State.DONE);
|
|
||||||
job.setModified(OffsetDateTime.now());
|
|
||||||
job.setLockedBy(null);
|
|
||||||
job.setLockedAt(null);
|
|
||||||
this.jobRepository.save(job);
|
|
||||||
} catch (Exception e) {
|
|
||||||
final int attemptCount = job.getAttempts() + 1;
|
|
||||||
final boolean isDead = attemptCount >= job.getMaxAttempts();
|
|
||||||
final OffsetDateTime runAfter = isDead
|
|
||||||
? OffsetDateTime.now()
|
|
||||||
: OffsetDateTime.now().plusSeconds(getBackoffSeconds(attemptCount));
|
|
||||||
final String lastError = formatException(e);
|
|
||||||
|
|
||||||
job.setState(isDead ? Job.State.DEAD : Job.State.QUEUED);
|
|
||||||
job.setAttempts(attemptCount);
|
|
||||||
job.setRunAfter(runAfter);
|
|
||||||
job.setLastError(lastError);
|
|
||||||
job.setModified(OffsetDateTime.now());
|
|
||||||
job.setLockedBy(null);
|
|
||||||
job.setLockedAt(null);
|
|
||||||
|
|
||||||
this.jobRepository.save(job);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long getBackoffSeconds(int attemptCount) {
|
|
||||||
final long base = (long) Math.min(300, Math.pow(2, attemptCount));
|
|
||||||
final long jitter = ThreadLocalRandom.current().nextLong(0, 5);
|
|
||||||
return base + jitter;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String formatException(Exception e) {
|
|
||||||
final var sw = new StringWriter();
|
|
||||||
e.printStackTrace(new PrintWriter(sw));
|
|
||||||
final String s = sw.toString();
|
|
||||||
return s.length() <= 8000 ? s : s.substring(0, 8000);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package app.mealsmadeeasy.api.jwt;
|
package app.mealsmadeeasy.api.jwt;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.security.AuthToken;
|
import app.mealsmadeeasy.api.security.AuthToken;
|
||||||
|
import app.mealsmadeeasy.api.security.SimpleAuthToken;
|
||||||
import io.jsonwebtoken.JwtException;
|
import io.jsonwebtoken.JwtException;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import io.jsonwebtoken.io.Serializer;
|
import io.jsonwebtoken.io.Serializer;
|
||||||
@ -42,7 +43,7 @@ public final class JwtServiceImpl implements JwtService {
|
|||||||
.signWith(this.secretKey)
|
.signWith(this.secretKey)
|
||||||
.json(this.serializer)
|
.json(this.serializer)
|
||||||
.compact();
|
.compact();
|
||||||
return new AuthToken(
|
return new SimpleAuthToken(
|
||||||
token,
|
token,
|
||||||
this.accessTokenLifetime,
|
this.accessTokenLifetime,
|
||||||
LocalDateTime.ofInstant(expires, ZoneId.systemDefault())
|
LocalDateTime.ofInstant(expires, ZoneId.systemDefault())
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
package app.mealsmadeeasy.api.recipe;
|
package app.mealsmadeeasy.api.recipe;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.image.Image;
|
import app.mealsmadeeasy.api.image.S3ImageEntity;
|
||||||
import app.mealsmadeeasy.api.recipe.comment.RecipeComment;
|
import app.mealsmadeeasy.api.recipe.comment.RecipeComment;
|
||||||
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
|
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
@ -12,9 +12,9 @@ import java.time.OffsetDateTime;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@Entity
|
@Entity(name = "Recipe")
|
||||||
@Data
|
@Data
|
||||||
public class Recipe {
|
public final class Recipe {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@ -75,10 +75,9 @@ public class Recipe {
|
|||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "main_image_id")
|
@JoinColumn(name = "main_image_id")
|
||||||
@Nullable
|
private S3ImageEntity mainImage;
|
||||||
private Image mainImage;
|
|
||||||
|
|
||||||
@OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
|
@OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
private RecipeEmbedding embedding;
|
private RecipeEmbeddingEntity embedding;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
package app.mealsmadeeasy.api.recipe;
|
package app.mealsmadeeasy.api.recipe;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.image.ImageException;
|
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.RecipeSearchBody;
|
||||||
import app.mealsmadeeasy.api.recipe.body.RecipeUpdateBody;
|
|
||||||
import app.mealsmadeeasy.api.recipe.comment.RecipeComment;
|
import app.mealsmadeeasy.api.recipe.comment.RecipeComment;
|
||||||
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentCreateBody;
|
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentCreateBody;
|
||||||
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentService;
|
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentService;
|
||||||
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentView;
|
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentView;
|
||||||
|
import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec;
|
||||||
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
|
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
|
||||||
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
|
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
|
||||||
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
|
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
|
||||||
@ -95,11 +94,10 @@ public class RecipeController {
|
|||||||
@PathVariable String username,
|
@PathVariable String username,
|
||||||
@PathVariable String slug,
|
@PathVariable String slug,
|
||||||
@RequestParam(defaultValue = "true") boolean includeRawText,
|
@RequestParam(defaultValue = "true") boolean includeRawText,
|
||||||
@RequestBody RecipeUpdateBody updateBody,
|
@RequestBody RecipeUpdateSpec updateSpec,
|
||||||
@AuthenticationPrincipal User principal
|
@AuthenticationPrincipal User principal
|
||||||
) throws ImageException, RecipeException {
|
) throws ImageException, RecipeException {
|
||||||
final RecipeUpdateSpec spec = RecipeUpdateSpec.from(updateBody);
|
final Recipe updated = this.recipeService.update(username, slug, updateSpec, principal);
|
||||||
final Recipe updated = this.recipeService.update(username, slug, spec, principal);
|
|
||||||
final FullRecipeView view = this.recipeService.toFullRecipeView(updated, includeRawText, principal);
|
final FullRecipeView view = this.recipeService.toFullRecipeView(updated, includeRawText, principal);
|
||||||
return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, principal));
|
return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, principal));
|
||||||
}
|
}
|
||||||
@ -119,9 +117,9 @@ public class RecipeController {
|
|||||||
@AuthenticationPrincipal User user
|
@AuthenticationPrincipal User user
|
||||||
) {
|
) {
|
||||||
if (recipeSearchBody.getType() == RecipeSearchBody.Type.AI_PROMPT) {
|
if (recipeSearchBody.getType() == RecipeSearchBody.Type.AI_PROMPT) {
|
||||||
final RecipeAiSearchBody spec = this.objectMapper.convertValue(
|
final RecipeAiSearchSpec spec = this.objectMapper.convertValue(
|
||||||
recipeSearchBody.getData(),
|
recipeSearchBody.getData(),
|
||||||
RecipeAiSearchBody.class
|
RecipeAiSearchSpec.class
|
||||||
);
|
);
|
||||||
final List<RecipeInfoView> results = this.recipeService.aiSearch(spec, user);
|
final List<RecipeInfoView> results = this.recipeService.aiSearch(spec, user);
|
||||||
return ResponseEntity.ok(Map.of("results", results));
|
return ResponseEntity.ok(Map.of("results", results));
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.recipe;
|
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.image.Image;
|
|
||||||
import app.mealsmadeeasy.api.user.User;
|
|
||||||
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Data;
|
|
||||||
import org.hibernate.annotations.Type;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "recipe_draft")
|
|
||||||
@Data
|
|
||||||
public class RecipeDraft {
|
|
||||||
|
|
||||||
public enum State {
|
|
||||||
INFER,
|
|
||||||
ENTER_DATA
|
|
||||||
}
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public static class RecipeDraftInference {
|
|
||||||
private OffsetDateTime inferredAt;
|
|
||||||
private String title;
|
|
||||||
private String rawText;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.UUID)
|
|
||||||
@Column(nullable = false, unique = true, updatable = false)
|
|
||||||
private UUID id;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private OffsetDateTime created;
|
|
||||||
private @Nullable OffsetDateTime modified;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private State state;
|
|
||||||
|
|
||||||
private @Nullable String slug;
|
|
||||||
private @Nullable String title;
|
|
||||||
private @Nullable Integer preparationTime;
|
|
||||||
private @Nullable Integer cookingTime;
|
|
||||||
private @Nullable Integer totalTime;
|
|
||||||
private @Nullable String rawText;
|
|
||||||
|
|
||||||
@ManyToOne(optional = false)
|
|
||||||
@JoinColumn(name = "owner_id", nullable = false)
|
|
||||||
private User owner;
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "main_image_id")
|
|
||||||
private @Nullable Image mainImage;
|
|
||||||
|
|
||||||
@Type(JsonBinaryType.class)
|
|
||||||
@Column(columnDefinition = "jsonb")
|
|
||||||
private @Nullable List<RecipeDraftInference> inferences;
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.recipe;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface RecipeDraftRepository extends JpaRepository<RecipeDraft, UUID> {}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.recipe;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Data;
|
|
||||||
import org.hibernate.annotations.Array;
|
|
||||||
import org.hibernate.annotations.JdbcTypeCode;
|
|
||||||
import org.hibernate.type.SqlTypes;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "recipe_embedding")
|
|
||||||
@Data
|
|
||||||
public class RecipeEmbedding {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
private Integer id;
|
|
||||||
|
|
||||||
@OneToOne(fetch = FetchType.LAZY, optional = false)
|
|
||||||
@MapsId
|
|
||||||
@JoinColumn(name = "recipe_id")
|
|
||||||
private Recipe recipe;
|
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.VECTOR)
|
|
||||||
@Array(length = 1024)
|
|
||||||
@Nullable
|
|
||||||
private float[] embedding;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private OffsetDateTime timestamp;
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package app.mealsmadeeasy.api.recipe;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.Array;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "recipe_embedding")
|
||||||
|
public class RecipeEmbeddingEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@OneToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@MapsId
|
||||||
|
@JoinColumn(name = "recipe_id")
|
||||||
|
private Recipe recipe;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.VECTOR)
|
||||||
|
@Array(length = 1024)
|
||||||
|
@Nullable
|
||||||
|
private float[] embedding;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private OffsetDateTime timestamp;
|
||||||
|
|
||||||
|
public Integer getId() {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Integer id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Recipe getRecipe() {
|
||||||
|
return this.recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRecipe(Recipe recipe) {
|
||||||
|
this.recipe = recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float[] getEmbedding() {
|
||||||
|
return this.embedding;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEmbedding(float[] embedding) {
|
||||||
|
this.embedding = embedding;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getTimestamp() {
|
||||||
|
return this.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestamp(OffsetDateTime timestamp) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface RecipeRepository extends JpaRepository<Recipe, Integer> {
|
public interface RecipeRepository extends JpaRepository<Recipe, Long> {
|
||||||
|
|
||||||
List<Recipe> findAllByIsPublicIsTrue();
|
List<Recipe> findAllByIsPublicIsTrue();
|
||||||
|
|
||||||
|
|||||||
@ -2,82 +2,12 @@ package app.mealsmadeeasy.api.recipe;
|
|||||||
|
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
import org.jetbrains.annotations.Nullable;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,89 @@
|
|||||||
|
package app.mealsmadeeasy.api.recipe;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.user.User;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@Component("recipeSecurity")
|
||||||
|
public class RecipeSecurityImpl implements RecipeSecurity {
|
||||||
|
|
||||||
|
private final RecipeRepository recipeRepository;
|
||||||
|
|
||||||
|
public RecipeSecurityImpl(RecipeRepository recipeRepository) {
|
||||||
|
this.recipeRepository = recipeRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isOwner(Recipe recipe, User user) {
|
||||||
|
return recipe.getOwner() != null && recipe.getOwner().getId().equals(user.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isOwner(long recipeId, User user) throws RecipeException {
|
||||||
|
final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException(
|
||||||
|
RecipeException.Type.INVALID_ID,
|
||||||
|
"No such Recipe with id " + recipeId
|
||||||
|
));
|
||||||
|
return this.isOwner(recipe, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isOwner(String username, String slug, @Nullable User user) throws RecipeException {
|
||||||
|
final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(
|
||||||
|
() -> new RecipeException(
|
||||||
|
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
|
||||||
|
"No such Recipe for username " + username + " and slug " + slug
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return this.isOwner(recipe, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException {
|
||||||
|
if (recipe.getIsPublic()) {
|
||||||
|
// public recipe
|
||||||
|
return true;
|
||||||
|
} else if (user == null) {
|
||||||
|
// a non-public recipe with no principal
|
||||||
|
return false;
|
||||||
|
} else if (Objects.equals(recipe.getOwner().getId(), user.getId())) {
|
||||||
|
// is owner
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// check if viewer
|
||||||
|
final Recipe withViewers = this.recipeRepository.findByIdWithViewers(recipe.getId())
|
||||||
|
.orElseThrow(() -> new RecipeException(
|
||||||
|
RecipeException.Type.INVALID_ID, "No such Recipe with id: " + recipe.getId()
|
||||||
|
));
|
||||||
|
for (final User viewer : withViewers.getViewers()) {
|
||||||
|
if (viewer.getId() != null && viewer.getId().equals(user.getId())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// non-public recipe and not viewer
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException {
|
||||||
|
final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(ownerUsername, slug)
|
||||||
|
.orElseThrow(() -> new RecipeException(
|
||||||
|
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
|
||||||
|
"No such Recipe for username " + ownerUsername + " and slug: " + slug
|
||||||
|
));
|
||||||
|
return this.isViewableBy(recipe, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isViewableBy(long recipeId, @Nullable User user) throws RecipeException {
|
||||||
|
final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException(
|
||||||
|
RecipeException.Type.INVALID_ID,
|
||||||
|
"No such Recipe with id: " + recipeId
|
||||||
|
));
|
||||||
|
return this.isViewableBy(recipe, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,366 +1,63 @@
|
|||||||
package app.mealsmadeeasy.api.recipe;
|
package app.mealsmadeeasy.api.recipe;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.file.File;
|
|
||||||
import app.mealsmadeeasy.api.image.Image;
|
|
||||||
import app.mealsmadeeasy.api.image.ImageException;
|
import app.mealsmadeeasy.api.image.ImageException;
|
||||||
import app.mealsmadeeasy.api.image.ImageService;
|
import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec;
|
||||||
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.RecipeCreateSpec;
|
||||||
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
|
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
|
||||||
import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository;
|
|
||||||
import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
|
import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
|
||||||
import app.mealsmadeeasy.api.recipe.view.RecipeInfoView;
|
import app.mealsmadeeasy.api.recipe.view.RecipeInfoView;
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
import jakarta.transaction.Transactional;
|
|
||||||
import org.jetbrains.annotations.ApiStatus;
|
import org.jetbrains.annotations.ApiStatus;
|
||||||
import org.jetbrains.annotations.Contract;
|
import org.jetbrains.annotations.Contract;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
import org.springframework.ai.embedding.EmbeddingModel;
|
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Slice;
|
import org.springframework.data.domain.Slice;
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
|
||||||
import org.springframework.security.access.prepost.PostAuthorize;
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Service
|
public interface RecipeService {
|
||||||
public class RecipeService {
|
|
||||||
|
|
||||||
private final RecipeRepository recipeRepository;
|
Recipe create(@Nullable User owner, RecipeCreateSpec spec);
|
||||||
private final RecipeStarRepository recipeStarRepository;
|
|
||||||
private final ImageService imageService;
|
|
||||||
private final MarkdownService markdownService;
|
|
||||||
private final EmbeddingModel embeddingModel;
|
|
||||||
private final RecipeDraftRepository recipeDraftRepository;
|
|
||||||
private final JobService jobService;
|
|
||||||
|
|
||||||
public RecipeService(
|
Recipe getById(long id, @Nullable User viewer) throws RecipeException;
|
||||||
RecipeRepository recipeRepository,
|
Recipe getByIdWithStars(long id, @Nullable User viewer) throws RecipeException;
|
||||||
RecipeStarRepository recipeStarRepository,
|
Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) throws RecipeException;
|
||||||
ImageService imageService,
|
|
||||||
MarkdownService markdownService,
|
|
||||||
EmbeddingModel embeddingModel,
|
|
||||||
RecipeDraftRepository recipeDraftRepository,
|
|
||||||
JobService jobService
|
|
||||||
) {
|
|
||||||
this.recipeRepository = recipeRepository;
|
|
||||||
this.recipeStarRepository = recipeStarRepository;
|
|
||||||
this.imageService = imageService;
|
|
||||||
this.markdownService = markdownService;
|
|
||||||
this.embeddingModel = embeddingModel;
|
|
||||||
this.recipeDraftRepository = recipeDraftRepository;
|
|
||||||
this.jobService = jobService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Recipe create(@Nullable User owner, RecipeCreateSpec spec) {
|
FullRecipeView getFullViewById(long id, @Nullable User viewer) throws RecipeException;
|
||||||
if (owner == null) {
|
FullRecipeView getFullViewByUsernameAndSlug(
|
||||||
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 username,
|
||||||
String slug,
|
String slug,
|
||||||
boolean includeRawText,
|
boolean includeRawText,
|
||||||
@Nullable User viewer
|
@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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Slice<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer) {
|
Slice<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer);
|
||||||
return this.recipeRepository.findAllViewableBy(viewer, pageable).map(recipe ->
|
List<Recipe> getByMinimumStars(long minimumStars, @Nullable User viewer);
|
||||||
this.getInfoView(recipe, viewer)
|
List<Recipe> getPublicRecipes();
|
||||||
);
|
List<Recipe> getRecipesViewableBy(User viewer);
|
||||||
}
|
List<Recipe> getRecipesOwnedBy(User owner);
|
||||||
|
|
||||||
public List<Recipe> getByMinimumStars(long minimumStars, @Nullable User viewer) {
|
List<RecipeInfoView> aiSearch(RecipeAiSearchSpec searchSpec, @Nullable User viewer);
|
||||||
return List.copyOf(
|
|
||||||
this.recipeRepository.findAllViewableByStarsGreaterThanEqual(minimumStars, viewer)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Recipe> getPublicRecipes() {
|
Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
|
||||||
return List.copyOf(this.recipeRepository.findAllByIsPublicIsTrue());
|
throws RecipeException, ImageException;
|
||||||
}
|
|
||||||
|
|
||||||
public List<Recipe> getRecipesViewableBy(User viewer) {
|
Recipe addViewer(long id, User modifier, User viewer) throws RecipeException;
|
||||||
return List.copyOf(this.recipeRepository.findAllByViewersContaining(viewer));
|
Recipe removeViewer(long id, User modifier, User viewer) throws RecipeException;
|
||||||
}
|
Recipe clearAllViewers(long id, User modifier) throws RecipeException;
|
||||||
|
|
||||||
public List<Recipe> getRecipesOwnedBy(User owner) {
|
void deleteRecipe(long id, User modifier);
|
||||||
return List.copyOf(this.recipeRepository.findAllByOwner(owner));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<RecipeInfoView> aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) {
|
FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer);
|
||||||
final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt());
|
RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer);
|
||||||
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")
|
@Contract("_, _, null -> null")
|
||||||
public @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer) {
|
@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")
|
@Contract("_, _, null -> null")
|
||||||
public @Nullable Boolean isOwner(String username, String slug, @Nullable User viewer) {
|
@Nullable Boolean isOwner(String username, String slug, @Nullable User viewer);
|
||||||
if (viewer == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return viewer.getUsername().equals(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
public RecipeDraft createDraft(User owner) {
|
@ApiStatus.Internal
|
||||||
final var recipeDraft = new RecipeDraft();
|
String getRenderedMarkdown(Recipe entity);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,316 @@
|
|||||||
|
package app.mealsmadeeasy.api.recipe;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.image.Image;
|
||||||
|
import app.mealsmadeeasy.api.image.ImageException;
|
||||||
|
import app.mealsmadeeasy.api.image.ImageService;
|
||||||
|
import app.mealsmadeeasy.api.image.S3ImageEntity;
|
||||||
|
import app.mealsmadeeasy.api.image.view.ImageView;
|
||||||
|
import app.mealsmadeeasy.api.markdown.MarkdownService;
|
||||||
|
import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec;
|
||||||
|
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
|
||||||
|
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
|
||||||
|
import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository;
|
||||||
|
import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
|
||||||
|
import app.mealsmadeeasy.api.recipe.view.RecipeInfoView;
|
||||||
|
import app.mealsmadeeasy.api.user.User;
|
||||||
|
import org.jetbrains.annotations.ApiStatus;
|
||||||
|
import org.jetbrains.annotations.Contract;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.springframework.ai.embedding.EmbeddingModel;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.domain.Slice;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.access.prepost.PostAuthorize;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class RecipeServiceImpl implements RecipeService {
|
||||||
|
|
||||||
|
private final RecipeRepository recipeRepository;
|
||||||
|
private final RecipeStarRepository recipeStarRepository;
|
||||||
|
private final ImageService imageService;
|
||||||
|
private final MarkdownService markdownService;
|
||||||
|
private final EmbeddingModel embeddingModel;
|
||||||
|
|
||||||
|
public RecipeServiceImpl(
|
||||||
|
RecipeRepository recipeRepository,
|
||||||
|
RecipeStarRepository recipeStarRepository,
|
||||||
|
ImageService imageService,
|
||||||
|
MarkdownService markdownService,
|
||||||
|
EmbeddingModel embeddingModel
|
||||||
|
) {
|
||||||
|
this.recipeRepository = recipeRepository;
|
||||||
|
this.recipeStarRepository = recipeStarRepository;
|
||||||
|
this.imageService = imageService;
|
||||||
|
this.markdownService = markdownService;
|
||||||
|
this.embeddingModel = embeddingModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Recipe create(@Nullable User owner, RecipeCreateSpec spec) {
|
||||||
|
if (owner == null) {
|
||||||
|
throw new AccessDeniedException("Must be logged in.");
|
||||||
|
}
|
||||||
|
final Recipe draft = new Recipe();
|
||||||
|
draft.setCreated(OffsetDateTime.now());
|
||||||
|
draft.setOwner((User) owner);
|
||||||
|
draft.setSlug(spec.getSlug());
|
||||||
|
draft.setTitle(spec.getTitle());
|
||||||
|
draft.setRawText(spec.getRawText());
|
||||||
|
draft.setMainImage((S3ImageEntity) spec.getMainImage());
|
||||||
|
draft.setIsPublic(spec.isPublic());
|
||||||
|
return this.recipeRepository.save(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Recipe findRecipeEntity(long id) throws RecipeException {
|
||||||
|
return this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException(
|
||||||
|
RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
|
||||||
|
public Recipe getById(long id, User viewer) throws RecipeException {
|
||||||
|
return this.findRecipeEntity(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
|
||||||
|
public Recipe getByIdWithStars(long id, @Nullable User viewer) throws RecipeException {
|
||||||
|
return this.recipeRepository.findByIdWithStars(id).orElseThrow(() -> new RecipeException(
|
||||||
|
RecipeException.Type.INVALID_ID,
|
||||||
|
"No such Recipe with id: " + id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
|
||||||
|
public Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) throws RecipeException {
|
||||||
|
return this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(() -> new RecipeException(
|
||||||
|
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
|
||||||
|
"No such Recipe for username " + username + " and slug " + slug
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@ApiStatus.Internal
|
||||||
|
public String getRenderedMarkdown(Recipe entity) {
|
||||||
|
if (entity.getCachedRenderedText() == null) {
|
||||||
|
entity.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(entity.getRawText()));
|
||||||
|
entity = this.recipeRepository.save(entity);
|
||||||
|
}
|
||||||
|
return entity.getCachedRenderedText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int getStarCount(Recipe recipe) {
|
||||||
|
return this.recipeRepository.getStarCount(recipe.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private int getViewerCount(long recipeId) {
|
||||||
|
return this.recipeRepository.getViewerCount(recipeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Contract("null, _ -> null")
|
||||||
|
private @Nullable ImageView getImageView(@Nullable Image image, @Nullable User viewer) {
|
||||||
|
if (image != null) {
|
||||||
|
return this.imageService.toImageView(image, viewer);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private FullRecipeView getFullView(Recipe recipe, boolean includeRawText, @Nullable User viewer) {
|
||||||
|
return FullRecipeView.from(
|
||||||
|
recipe,
|
||||||
|
this.getRenderedMarkdown(recipe),
|
||||||
|
includeRawText,
|
||||||
|
this.getStarCount(recipe),
|
||||||
|
this.getViewerCount(recipe.getId()),
|
||||||
|
this.getImageView(recipe.getMainImage(), viewer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecipeInfoView getInfoView(Recipe recipe, @Nullable User viewer) {
|
||||||
|
return RecipeInfoView.from(
|
||||||
|
recipe,
|
||||||
|
this.getStarCount(recipe),
|
||||||
|
this.getImageView(recipe.getMainImage(), viewer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PreAuthorize("@recipeSecurity.isViewableBy(#id, #viewer)")
|
||||||
|
public FullRecipeView getFullViewById(long id, @Nullable User viewer) throws RecipeException {
|
||||||
|
final Recipe recipe = this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException(
|
||||||
|
RecipeException.Type.INVALID_ID, "No such Recipe for id: " + id
|
||||||
|
));
|
||||||
|
return this.getFullView(recipe, false, viewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
|
||||||
|
public FullRecipeView getFullViewByUsernameAndSlug(
|
||||||
|
String username,
|
||||||
|
String slug,
|
||||||
|
boolean includeRawText,
|
||||||
|
@Nullable User viewer
|
||||||
|
) throws RecipeException {
|
||||||
|
final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug)
|
||||||
|
.orElseThrow(() -> new RecipeException(
|
||||||
|
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
|
||||||
|
"No such Recipe for username " + username + " and slug: " + slug
|
||||||
|
));
|
||||||
|
return this.getFullView(recipe, includeRawText, viewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Slice<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer) {
|
||||||
|
return this.recipeRepository.findAllViewableBy((User) viewer, pageable).map(recipe ->
|
||||||
|
this.getInfoView(recipe, viewer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Recipe> getByMinimumStars(long minimumStars, User viewer) {
|
||||||
|
return List.copyOf(
|
||||||
|
this.recipeRepository.findAllViewableByStarsGreaterThanEqual(minimumStars, (User) viewer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Recipe> getPublicRecipes() {
|
||||||
|
return List.copyOf(this.recipeRepository.findAllByIsPublicIsTrue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Recipe> getRecipesViewableBy(User viewer) {
|
||||||
|
return List.copyOf(this.recipeRepository.findAllByViewersContaining((User) viewer));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Recipe> getRecipesOwnedBy(User owner) {
|
||||||
|
return List.copyOf(this.recipeRepository.findAllByOwner((User) owner));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<RecipeInfoView> aiSearch(RecipeAiSearchSpec searchSpec, @Nullable User viewer) {
|
||||||
|
final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt());
|
||||||
|
final List<Recipe> results;
|
||||||
|
if (viewer == null) {
|
||||||
|
results = this.recipeRepository.searchByEmbeddingAndIsPublic(queryEmbedding, 0.5f);
|
||||||
|
} else {
|
||||||
|
results = this.recipeRepository.searchByEmbeddingAndViewableBy(queryEmbedding, 0.5f, viewer.getId());
|
||||||
|
}
|
||||||
|
return results.stream()
|
||||||
|
.map(recipeEntity -> this.getInfoView(recipeEntity, viewer))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)")
|
||||||
|
public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
|
||||||
|
throws RecipeException, ImageException {
|
||||||
|
final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(() ->
|
||||||
|
new RecipeException(
|
||||||
|
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
|
||||||
|
"No such Recipe for username " + username + " and slug: " + slug
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
recipe.setTitle(spec.getTitle());
|
||||||
|
recipe.setPreparationTime(spec.getPreparationTime());
|
||||||
|
recipe.setCookingTime(spec.getCookingTime());
|
||||||
|
recipe.setTotalTime(spec.getTotalTime());
|
||||||
|
recipe.setRawText(spec.getRawText());
|
||||||
|
recipe.setCachedRenderedText(null);
|
||||||
|
recipe.setIsPublic(spec.getIsPublic());
|
||||||
|
|
||||||
|
final S3ImageEntity mainImage;
|
||||||
|
if (spec.getMainImage() == null) {
|
||||||
|
mainImage = null;
|
||||||
|
} else {
|
||||||
|
mainImage = (S3ImageEntity) this.imageService.getByUsernameAndFilename(
|
||||||
|
spec.getMainImage().getUsername(),
|
||||||
|
spec.getMainImage().getFilename(),
|
||||||
|
modifier
|
||||||
|
);
|
||||||
|
}
|
||||||
|
recipe.setMainImage(mainImage);
|
||||||
|
|
||||||
|
recipe.setModified(OffsetDateTime.now());
|
||||||
|
return this.recipeRepository.save(recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
|
||||||
|
public Recipe addViewer(long id, User modifier, User viewer) throws RecipeException {
|
||||||
|
final Recipe entity = this.recipeRepository.findByIdWithViewers(id).orElseThrow(() -> new RecipeException(
|
||||||
|
RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id
|
||||||
|
));
|
||||||
|
final Set<User> viewers = new HashSet<>(entity.getViewers());
|
||||||
|
viewers.add((User) viewer);
|
||||||
|
entity.setViewers(viewers);
|
||||||
|
return this.recipeRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
|
||||||
|
public Recipe removeViewer(long id, User modifier, User viewer) throws RecipeException {
|
||||||
|
final Recipe entity = this.findRecipeEntity(id);
|
||||||
|
final Set<User> viewers = new HashSet<>(entity.getViewers());
|
||||||
|
viewers.remove((User) viewer);
|
||||||
|
entity.setViewers(viewers);
|
||||||
|
return this.recipeRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
|
||||||
|
public Recipe clearAllViewers(long id, User modifier) throws RecipeException {
|
||||||
|
final Recipe entity = this.findRecipeEntity(id);
|
||||||
|
entity.setViewers(new HashSet<>());
|
||||||
|
return this.recipeRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
|
||||||
|
public void deleteRecipe(long id, User modifier) {
|
||||||
|
this.recipeRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer) {
|
||||||
|
return this.getFullView((Recipe) recipe, includeRawText, viewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer) {
|
||||||
|
return this.getInfoView((Recipe) recipe, viewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
|
||||||
|
@Contract("_, _, null -> null")
|
||||||
|
public @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer) {
|
||||||
|
if (viewer == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.recipeStarRepository.isStarer(username, slug, viewer.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
|
||||||
|
@Contract("_, _, null -> null")
|
||||||
|
public @Nullable Boolean isOwner(String username, String slug, @Nullable User viewer) {
|
||||||
|
if (viewer == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return viewer.getUsername().equals(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,8 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.recipe.body;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class RecipeAiSearchBody {
|
|
||||||
private String prompt;
|
|
||||||
}
|
|
||||||
@ -1,10 +1,7 @@
|
|||||||
package app.mealsmadeeasy.api.recipe.body;
|
package app.mealsmadeeasy.api.recipe.body;
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Data
|
|
||||||
public class RecipeSearchBody {
|
public class RecipeSearchBody {
|
||||||
|
|
||||||
public enum Type {
|
public enum Type {
|
||||||
@ -14,4 +11,20 @@ public class RecipeSearchBody {
|
|||||||
private Type type;
|
private Type type;
|
||||||
private Map<String, Object> data;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.recipe.body;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class RecipeUpdateBody {
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public static class MainImageUpdateBody {
|
|
||||||
private String username;
|
|
||||||
private String filename;
|
|
||||||
}
|
|
||||||
|
|
||||||
private @Nullable String title;
|
|
||||||
private @Nullable Integer preparationTime;
|
|
||||||
private @Nullable Integer cookingTime;
|
|
||||||
private @Nullable Integer totalTime;
|
|
||||||
private @Nullable String rawText;
|
|
||||||
private @Nullable Boolean isPublic;
|
|
||||||
private @Nullable MainImageUpdateBody mainImage;
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -8,7 +8,6 @@ import lombok.Data;
|
|||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "recipe_comment")
|
|
||||||
@Data
|
@Data
|
||||||
public final class RecipeComment {
|
public final class RecipeComment {
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,15 @@
|
|||||||
package app.mealsmadeeasy.api.recipe.comment;
|
package app.mealsmadeeasy.api.recipe.comment;
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class RecipeCommentCreateBody {
|
public class RecipeCommentCreateBody {
|
||||||
|
|
||||||
private String text;
|
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.domain.Slice;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface RecipeCommentRepository extends JpaRepository<RecipeComment, Integer> {
|
public interface RecipeCommentRepository extends JpaRepository<RecipeComment, Long> {
|
||||||
void deleteAllByRecipe(Recipe recipe);
|
void deleteAllByRecipe(Recipe recipe);
|
||||||
Slice<RecipeComment> findAllByRecipe(Recipe recipe, Pageable pageable);
|
Slice<RecipeComment> findAllByRecipe(Recipe recipe, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,9 +8,9 @@ import org.springframework.data.domain.Slice;
|
|||||||
public interface RecipeCommentService {
|
public interface RecipeCommentService {
|
||||||
RecipeComment create(String recipeUsername, String recipeSlug, User owner, RecipeCommentCreateBody body)
|
RecipeComment create(String recipeUsername, String recipeSlug, User owner, RecipeCommentCreateBody body)
|
||||||
throws RecipeException;
|
throws RecipeException;
|
||||||
RecipeComment get(Integer commentId, User viewer) throws RecipeException;
|
RecipeComment get(long commentId, User viewer) throws RecipeException;
|
||||||
Slice<RecipeCommentView> getComments(String recipeUsername, String recipeSlug, Pageable pageable, User viewer)
|
Slice<RecipeCommentView> getComments(String recipeUsername, String recipeSlug, Pageable pageable, User viewer)
|
||||||
throws RecipeException;
|
throws RecipeException;
|
||||||
RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException;
|
RecipeComment update(long commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException;
|
||||||
void delete(Integer commentId, User modifier) throws RecipeException;
|
void delete(long commentId, User modifier) throws RecipeException;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,14 +56,14 @@ public class RecipeCommentServiceImpl implements RecipeCommentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject.recipe, #viewer)")
|
@PostAuthorize("@recipeSecurity.isViewableBy(returnObject.recipe, #viewer)")
|
||||||
private RecipeComment loadCommentEntity(Integer commentId, User viewer) throws RecipeException {
|
private RecipeComment loadCommentEntity(long commentId, User viewer) throws RecipeException {
|
||||||
return this.recipeCommentRepository.findById(commentId).orElseThrow(() -> new RecipeException(
|
return this.recipeCommentRepository.findById(commentId).orElseThrow(() -> new RecipeException(
|
||||||
RecipeException.Type.INVALID_COMMENT_ID, "No such RecipeComment for id: " + commentId
|
RecipeException.Type.INVALID_COMMENT_ID, "No such RecipeComment for id: " + commentId
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RecipeComment get(Integer commentId, User viewer) throws RecipeException {
|
public RecipeComment get(long commentId, User viewer) throws RecipeException {
|
||||||
return this.loadCommentEntity(commentId, viewer);
|
return this.loadCommentEntity(commentId, viewer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,21 +84,21 @@ public class RecipeCommentServiceImpl implements RecipeCommentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RecipeComment update(Integer commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException {
|
public RecipeComment update(long commentId, User viewer, RecipeCommentUpdateSpec spec) throws RecipeException {
|
||||||
final RecipeComment entity = this.loadCommentEntity(commentId, viewer);
|
final RecipeComment entity = this.loadCommentEntity(commentId, viewer);
|
||||||
entity.setRawText(spec.getRawText());
|
entity.setRawText(spec.getRawText());
|
||||||
return this.recipeCommentRepository.save(entity);
|
return this.recipeCommentRepository.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostAuthorize("@recipeSecurity.isOwner(returnObject.recipe, #modifier)")
|
@PostAuthorize("@recipeSecurity.isOwner(returnObject.recipe, #modifier)")
|
||||||
private RecipeComment loadForDelete(Integer commentId, User modifier) throws RecipeException {
|
private RecipeComment loadForDelete(long commentId, User modifier) throws RecipeException {
|
||||||
return this.recipeCommentRepository.findById(commentId).orElseThrow(() -> new RecipeException(
|
return this.recipeCommentRepository.findById(commentId).orElseThrow(() -> new RecipeException(
|
||||||
RecipeException.Type.INVALID_COMMENT_ID, "No such RecipeComment for id: " + commentId
|
RecipeException.Type.INVALID_COMMENT_ID, "No such RecipeComment for id: " + commentId
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(Integer commentId, User modifier) throws RecipeException {
|
public void delete(long commentId, User modifier) throws RecipeException {
|
||||||
final RecipeComment entityToDelete = this.loadForDelete(commentId, modifier);
|
final RecipeComment entityToDelete = this.loadForDelete(commentId, modifier);
|
||||||
this.recipeCommentRepository.delete(entityToDelete);
|
this.recipeCommentRepository.delete(entityToDelete);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
package app.mealsmadeeasy.api.recipe.comment;
|
package app.mealsmadeeasy.api.recipe.comment;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
@Value
|
|
||||||
@Builder
|
|
||||||
public class RecipeCommentUpdateSpec {
|
public class RecipeCommentUpdateSpec {
|
||||||
String rawText;
|
|
||||||
|
private String rawText;
|
||||||
|
|
||||||
|
public String getRawText() {
|
||||||
|
return this.rawText;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRawText(String rawText) {
|
||||||
|
this.rawText = rawText;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,36 +1,88 @@
|
|||||||
package app.mealsmadeeasy.api.recipe.comment;
|
package app.mealsmadeeasy.api.recipe.comment;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.user.view.UserInfoView;
|
import app.mealsmadeeasy.api.user.view.UserInfoView;
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
@Value
|
|
||||||
@Builder
|
|
||||||
public class RecipeCommentView {
|
public class RecipeCommentView {
|
||||||
|
|
||||||
public static RecipeCommentView from(RecipeComment comment, boolean includeRawText) {
|
public static RecipeCommentView from(RecipeComment comment, boolean includeRawText) {
|
||||||
final var builder = RecipeCommentView.builder()
|
final RecipeCommentView view = new RecipeCommentView();
|
||||||
.id(comment.getId())
|
view.setId(comment.getId());
|
||||||
.created(comment.getCreated())
|
view.setCreated(comment.getCreated());
|
||||||
.modified(comment.getModified())
|
view.setModified(comment.getModified());
|
||||||
.text(comment.getCachedRenderedText())
|
view.setText(((RecipeComment) comment).getCachedRenderedText());
|
||||||
.owner(UserInfoView.from(comment.getOwner()))
|
|
||||||
.recipeId(comment.getRecipe().getId());
|
|
||||||
if (includeRawText) {
|
if (includeRawText) {
|
||||||
builder.rawText(comment.getRawText());
|
view.setRawText(comment.getRawText());
|
||||||
}
|
}
|
||||||
return builder.build();
|
view.setOwner(UserInfoView.from(comment.getOwner()));
|
||||||
|
view.setRecipeId(comment.getRecipe().getId());
|
||||||
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
Integer id;
|
private Integer id;
|
||||||
OffsetDateTime created;
|
private OffsetDateTime created;
|
||||||
@Nullable OffsetDateTime modified;
|
private @Nullable OffsetDateTime modified;
|
||||||
String text;
|
private String text;
|
||||||
@Nullable String rawText;
|
private @Nullable String rawText;
|
||||||
UserInfoView owner;
|
private UserInfoView owner;
|
||||||
Integer recipeId;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.recipe.job;
|
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.file.File;
|
|
||||||
import app.mealsmadeeasy.api.file.FileService;
|
|
||||||
import app.mealsmadeeasy.api.job.Job;
|
|
||||||
import app.mealsmadeeasy.api.job.JobHandler;
|
|
||||||
import app.mealsmadeeasy.api.recipe.RecipeDraft;
|
|
||||||
import app.mealsmadeeasy.api.recipe.RecipeService;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
|
||||||
import org.springframework.ai.chat.messages.Message;
|
|
||||||
import org.springframework.ai.chat.messages.UserMessage;
|
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
|
||||||
import org.springframework.ai.chat.model.ChatResponse;
|
|
||||||
import org.springframework.ai.chat.prompt.Prompt;
|
|
||||||
import org.springframework.ai.content.Media;
|
|
||||||
import org.springframework.ai.ollama.api.OllamaChatOptions;
|
|
||||||
import org.springframework.core.io.InputStreamResource;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.util.MimeType;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload> {
|
|
||||||
|
|
||||||
public static final String JOB_KEY = "RECIPE_INFER_JOB";
|
|
||||||
|
|
||||||
private final FileService fileService;
|
|
||||||
private final RecipeService recipeService;
|
|
||||||
private final ChatModel chatModel;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Class<RecipeInferJobPayload> getPayloadType() {
|
|
||||||
return RecipeInferJobPayload.class;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getJobKey() {
|
|
||||||
return "RECIPE_INFER_JOB";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handle(Job job, RecipeInferJobPayload payload) {
|
|
||||||
final File sourceFile = this.fileService.getById(payload.fileId());
|
|
||||||
final InputStream sourceFileContent;
|
|
||||||
try {
|
|
||||||
sourceFileContent = this.fileService.getFileContentById(payload.fileId());
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Media sourceFileMedia = Media.builder()
|
|
||||||
.data(new InputStreamResource(sourceFileContent))
|
|
||||||
.mimeType(MimeType.valueOf(sourceFile.getMimeType()))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
final Message ocrMessage = UserMessage.builder()
|
|
||||||
.text("Convert the recipe in the image to Markdown.")
|
|
||||||
.media(sourceFileMedia)
|
|
||||||
.build();
|
|
||||||
final Prompt ocrPrompt = Prompt.builder()
|
|
||||||
.messages(ocrMessage)
|
|
||||||
.chatOptions(OllamaChatOptions.builder().model("deepseek-ocr:latest").build())
|
|
||||||
.build();
|
|
||||||
final ChatResponse ocrResponse = this.chatModel.call(ocrPrompt);
|
|
||||||
final String fullMarkdownText = ocrResponse.getResult().getOutput().getText();
|
|
||||||
|
|
||||||
// get recipe draft
|
|
||||||
final @Nullable RecipeDraft recipeDraft = this.recipeService.getDraftById(payload.recipeDraftId());
|
|
||||||
if (recipeDraft == null) {
|
|
||||||
throw new RuntimeException("Recipe draft not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// set props on draft from ai output, etc.
|
|
||||||
recipeDraft.setRawText(fullMarkdownText);
|
|
||||||
recipeDraft.setState(RecipeDraft.State.ENTER_DATA);
|
|
||||||
|
|
||||||
if (recipeDraft.getInferences() == null) {
|
|
||||||
recipeDraft.setInferences(new ArrayList<>());
|
|
||||||
}
|
|
||||||
final RecipeDraft.RecipeDraftInference inference = new RecipeDraft.RecipeDraftInference();
|
|
||||||
inference.setTitle("TODO: inferred title");
|
|
||||||
inference.setRawText(fullMarkdownText);
|
|
||||||
inference.setInferredAt(OffsetDateTime.now());
|
|
||||||
recipeDraft.getInferences().add(inference);
|
|
||||||
|
|
||||||
this.recipeService.saveDraft(recipeDraft);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.recipe.job;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record RecipeInferJobPayload(UUID recipeDraftId, UUID fileId) {}
|
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package app.mealsmadeeasy.api.recipe.spec;
|
||||||
|
|
||||||
|
public class RecipeAiSearchSpec {
|
||||||
|
|
||||||
|
private String prompt;
|
||||||
|
|
||||||
|
public String getPrompt() {
|
||||||
|
return this.prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPrompt(String prompt) {
|
||||||
|
this.prompt = prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,19 +1,81 @@
|
|||||||
package app.mealsmadeeasy.api.recipe.spec;
|
package app.mealsmadeeasy.api.recipe.spec;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.image.Image;
|
import app.mealsmadeeasy.api.image.Image;
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
@Value
|
|
||||||
@Builder
|
|
||||||
public class RecipeCreateSpec {
|
public class RecipeCreateSpec {
|
||||||
String slug;
|
|
||||||
String title;
|
private String slug;
|
||||||
@Nullable Integer preparationTime;
|
private String title;
|
||||||
@Nullable Integer cookingTime;
|
private @Nullable Integer preparationTime;
|
||||||
@Nullable Integer totalTime;
|
private @Nullable Integer cookingTime;
|
||||||
String rawText;
|
private @Nullable Integer totalTime;
|
||||||
boolean isPublic;
|
private String rawText;
|
||||||
@Nullable Image mainImage;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,73 +2,120 @@ package app.mealsmadeeasy.api.recipe.spec;
|
|||||||
|
|
||||||
import app.mealsmadeeasy.api.image.Image;
|
import app.mealsmadeeasy.api.image.Image;
|
||||||
import app.mealsmadeeasy.api.recipe.Recipe;
|
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;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
// For now, we cannot change slug after creation.
|
// For now, we cannot change slug after creation.
|
||||||
// In the future, we may be able to have redirects from
|
// In the future, we may be able to have redirects from
|
||||||
// old slugs to new slugs.
|
// old slugs to new slugs.
|
||||||
@Value
|
|
||||||
@Builder
|
|
||||||
public class RecipeUpdateSpec {
|
public class RecipeUpdateSpec {
|
||||||
|
|
||||||
@Value
|
|
||||||
@Builder
|
|
||||||
public static class MainImageUpdateSpec {
|
public static class MainImageUpdateSpec {
|
||||||
String username;
|
|
||||||
String filename;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static RecipeUpdateSpec from(RecipeUpdateBody body) {
|
private String username;
|
||||||
final var b = RecipeUpdateSpec.builder()
|
private String filename;
|
||||||
.title(body.getTitle())
|
|
||||||
.preparationTime(body.getPreparationTime())
|
public String getUsername() {
|
||||||
.cookingTime(body.getCookingTime())
|
return this.username;
|
||||||
.totalTime(body.getTotalTime())
|
|
||||||
.rawText(body.getRawText())
|
|
||||||
.isPublic(body.getIsPublic());
|
|
||||||
final @Nullable RecipeUpdateBody.MainImageUpdateBody mainImage = body.getMainImage();
|
|
||||||
if (mainImage != null) {
|
|
||||||
b.mainImage(
|
|
||||||
MainImageUpdateSpec.builder()
|
|
||||||
.username(mainImage.getUsername())
|
|
||||||
.filename(mainImage.getFilename())
|
|
||||||
.build()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return b.build();
|
|
||||||
|
public void setUsername(String username) {
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFilename() {
|
||||||
|
return this.filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFilename(String filename) {
|
||||||
|
this.filename = filename;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For testing convenience only.
|
private String title;
|
||||||
@ApiStatus.Internal
|
private @Nullable Integer preparationTime;
|
||||||
public static RecipeUpdateSpec.RecipeUpdateSpecBuilder fromRecipeToBuilder(Recipe recipe) {
|
private @Nullable Integer cookingTime;
|
||||||
final var b = RecipeUpdateSpec.builder()
|
private @Nullable Integer totalTime;
|
||||||
.title(recipe.getTitle())
|
private String rawText;
|
||||||
.preparationTime(recipe.getPreparationTime())
|
private boolean isPublic;
|
||||||
.cookingTime(recipe.getCookingTime())
|
private @Nullable MainImageUpdateSpec mainImage;
|
||||||
.totalTime(recipe.getTotalTime())
|
|
||||||
.rawText(recipe.getRawText())
|
public RecipeUpdateSpec() {}
|
||||||
.isPublic(recipe.getIsPublic());
|
|
||||||
|
/**
|
||||||
|
* 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();
|
final @Nullable Image mainImage = recipe.getMainImage();
|
||||||
if (recipe.getMainImage() != null) {
|
if (mainImage != null) {
|
||||||
b.mainImage(MainImageUpdateSpec.builder()
|
this.mainImage = new MainImageUpdateSpec();
|
||||||
.username(mainImage.getOwner().getUsername())
|
this.mainImage.setUsername(mainImage.getOwner().getUsername());
|
||||||
.filename(mainImage.getUserFilename())
|
this.mainImage.setFilename(mainImage.getUserFilename());
|
||||||
.build()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String title;
|
public @Nullable String getTitle() {
|
||||||
@Nullable Integer preparationTime;
|
return this.title;
|
||||||
@Nullable Integer cookingTime;
|
}
|
||||||
@Nullable Integer totalTime;
|
|
||||||
String rawText;
|
public void setTitle(@Nullable String title) {
|
||||||
Boolean isPublic;
|
this.title = title;
|
||||||
@Nullable MainImageUpdateSpec mainImage;
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import lombok.Data;
|
|||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
@Entity
|
@Entity(name = "RecipeStar")
|
||||||
@Table(name = "recipe_star")
|
@Table(name = "recipe_star")
|
||||||
@Data
|
@Data
|
||||||
public final class RecipeStar {
|
public final class RecipeStar {
|
||||||
|
|||||||
@ -2,10 +2,10 @@ package app.mealsmadeeasy.api.recipe.star;
|
|||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Embeddable;
|
import jakarta.persistence.Embeddable;
|
||||||
import lombok.Data;
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@Embeddable
|
@Embeddable
|
||||||
@Data
|
|
||||||
public class RecipeStarId {
|
public class RecipeStarId {
|
||||||
|
|
||||||
@Column(name = "owner_id", nullable = false)
|
@Column(name = "owner_id", nullable = false)
|
||||||
@ -14,4 +14,39 @@ public class RecipeStarId {
|
|||||||
@Column(name = "recipe_id", nullable = false)
|
@Column(name = "recipe_id", nullable = false)
|
||||||
private Integer recipeId;
|
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 RecipeStar draft = new RecipeStar();
|
||||||
final RecipeStarId id = new RecipeStarId();
|
final RecipeStarId id = new RecipeStarId();
|
||||||
id.setRecipeId(recipeId);
|
id.setRecipeId(recipeId);
|
||||||
id.setOwnerId(ownerId);
|
id.getOwnerId(ownerId);
|
||||||
draft.setId(id);
|
draft.setId(id);
|
||||||
draft.setTimestamp(OffsetDateTime.now());
|
draft.setTimestamp(OffsetDateTime.now());
|
||||||
return this.recipeStarRepository.save(draft);
|
return this.recipeStarRepository.save(draft);
|
||||||
|
|||||||
@ -4,14 +4,11 @@ import app.mealsmadeeasy.api.image.view.ImageView;
|
|||||||
import app.mealsmadeeasy.api.recipe.Recipe;
|
import app.mealsmadeeasy.api.recipe.Recipe;
|
||||||
import app.mealsmadeeasy.api.user.view.UserInfoView;
|
import app.mealsmadeeasy.api.user.view.UserInfoView;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import lombok.Builder;
|
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||||
import lombok.Value;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
@Value
|
|
||||||
@Builder
|
|
||||||
public class FullRecipeView {
|
public class FullRecipeView {
|
||||||
|
|
||||||
public static FullRecipeView from(
|
public static FullRecipeView from(
|
||||||
@ -22,46 +19,162 @@ public class FullRecipeView {
|
|||||||
int viewerCount,
|
int viewerCount,
|
||||||
@Nullable ImageView mainImage
|
@Nullable ImageView mainImage
|
||||||
) {
|
) {
|
||||||
final var b = FullRecipeView.builder()
|
final FullRecipeView view = new FullRecipeView();
|
||||||
.id(recipe.getId())
|
view.setId(recipe.getId());
|
||||||
.created(recipe.getCreated())
|
view.setCreated(recipe.getCreated());
|
||||||
.modified(recipe.getModified())
|
view.setModified(recipe.getModified());
|
||||||
.slug(recipe.getSlug())
|
view.setSlug(recipe.getSlug());
|
||||||
.title(recipe.getTitle())
|
view.setTitle(recipe.getTitle());
|
||||||
.preparationTime(recipe.getPreparationTime())
|
view.setPreparationTime(recipe.getPreparationTime());
|
||||||
.cookingTime(recipe.getCookingTime())
|
view.setCookingTime(recipe.getCookingTime());
|
||||||
.totalTime(recipe.getTotalTime())
|
view.setTotalTime(recipe.getTotalTime());
|
||||||
.text(renderedText)
|
view.setText(renderedText);
|
||||||
.owner(UserInfoView.from(recipe.getOwner()))
|
|
||||||
.starCount(starCount)
|
|
||||||
.viewerCount(viewerCount)
|
|
||||||
.mainImage(mainImage)
|
|
||||||
.isPublic(recipe.getIsPublic());
|
|
||||||
if (includeRawText) {
|
if (includeRawText) {
|
||||||
b.rawText(recipe.getRawText());
|
view.setRawText(recipe.getRawText());
|
||||||
}
|
}
|
||||||
return b.build();
|
view.setOwner(UserInfoView.from(recipe.getOwner()));
|
||||||
|
view.setStarCount(starCount);
|
||||||
|
view.setViewerCount(viewerCount);
|
||||||
|
view.setMainImage(mainImage);
|
||||||
|
view.setIsPublic(recipe.getIsPublic());
|
||||||
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
Integer id;
|
private long id;
|
||||||
OffsetDateTime created;
|
private OffsetDateTime created;
|
||||||
@Nullable OffsetDateTime modified;
|
private @Nullable OffsetDateTime modified;
|
||||||
String slug;
|
private String slug;
|
||||||
String title;
|
private String title;
|
||||||
@Nullable Integer preparationTime;
|
private @Nullable Integer preparationTime;
|
||||||
@Nullable Integer cookingTime;
|
private @Nullable Integer cookingTime;
|
||||||
@Nullable Integer totalTime;
|
private @Nullable Integer totalTime;
|
||||||
String text;
|
private String text;
|
||||||
@Nullable String rawText;
|
private @Nullable String rawText;
|
||||||
UserInfoView owner;
|
private UserInfoView owner;
|
||||||
int starCount;
|
private int starCount;
|
||||||
int viewerCount;
|
private int viewerCount;
|
||||||
@Nullable ImageView mainImage;
|
private @Nullable ImageView mainImage;
|
||||||
boolean isPublic;
|
private boolean isPublic;
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
public long getId() {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getCreated() {
|
||||||
|
return this.created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreated(OffsetDateTime created) {
|
||||||
|
this.created = created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable OffsetDateTime getModified() {
|
||||||
|
return this.modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setModified(@Nullable OffsetDateTime modified) {
|
||||||
|
this.modified = modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSlug() {
|
||||||
|
return this.slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSlug(String slug) {
|
||||||
|
this.slug = slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return this.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable Integer getPreparationTime() {
|
||||||
|
return this.preparationTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPreparationTime(@Nullable Integer preparationTime) {
|
||||||
|
this.preparationTime = preparationTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable Integer getCookingTime() {
|
||||||
|
return this.cookingTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCookingTime(@Nullable Integer cookingTime) {
|
||||||
|
this.cookingTime = cookingTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable Integer getTotalTime() {
|
||||||
|
return this.totalTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalTime(@Nullable Integer totalTime) {
|
||||||
|
this.totalTime = totalTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable String getText() {
|
||||||
|
return this.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setText(String text) {
|
||||||
|
this.text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonInclude(Include.NON_NULL)
|
||||||
public @Nullable String getRawText() {
|
public @Nullable String getRawText() {
|
||||||
return this.rawText;
|
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,8 +1,5 @@
|
|||||||
package app.mealsmadeeasy.api.recipe.view;
|
package app.mealsmadeeasy.api.recipe.view;
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
public final class RecipeExceptionView {
|
public final class RecipeExceptionView {
|
||||||
|
|
||||||
private final String type;
|
private final String type;
|
||||||
@ -13,4 +10,12 @@ public final class RecipeExceptionView {
|
|||||||
this.message = message;
|
this.message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getType() {
|
||||||
|
return this.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return this.message;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,44 +3,42 @@ package app.mealsmadeeasy.api.recipe.view;
|
|||||||
import app.mealsmadeeasy.api.image.view.ImageView;
|
import app.mealsmadeeasy.api.image.view.ImageView;
|
||||||
import app.mealsmadeeasy.api.recipe.Recipe;
|
import app.mealsmadeeasy.api.recipe.Recipe;
|
||||||
import app.mealsmadeeasy.api.user.view.UserInfoView;
|
import app.mealsmadeeasy.api.user.view.UserInfoView;
|
||||||
import lombok.Builder;
|
import lombok.Data;
|
||||||
import lombok.Value;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
@Value
|
@Data
|
||||||
@Builder
|
|
||||||
public class RecipeInfoView {
|
public class RecipeInfoView {
|
||||||
|
|
||||||
public static RecipeInfoView from(Recipe recipe, int starCount, @Nullable ImageView mainImage) {
|
public static RecipeInfoView from(Recipe recipe, int starCount, @Nullable ImageView mainImage) {
|
||||||
return RecipeInfoView.builder()
|
final RecipeInfoView view = new RecipeInfoView();
|
||||||
.id(recipe.getId())
|
view.setId(recipe.getId());
|
||||||
.created(recipe.getCreated())
|
view.setCreated(recipe.getCreated());
|
||||||
.modified(recipe.getModified())
|
view.setModified(recipe.getModified());
|
||||||
.slug(recipe.getSlug())
|
view.setSlug(recipe.getSlug());
|
||||||
.title(recipe.getTitle())
|
view.setTitle(recipe.getTitle());
|
||||||
.preparationTime(recipe.getPreparationTime())
|
view.setPreparationTime(recipe.getPreparationTime());
|
||||||
.cookingTime(recipe.getCookingTime())
|
view.setCookingTime(recipe.getCookingTime());
|
||||||
.totalTime(recipe.getTotalTime())
|
view.setTotalTime(recipe.getTotalTime());
|
||||||
.owner(UserInfoView.from(recipe.getOwner()))
|
view.setOwner(UserInfoView.from(recipe.getOwner()));
|
||||||
.isPublic(recipe.getIsPublic())
|
view.setPublic(recipe.getIsPublic());
|
||||||
.starCount(starCount)
|
view.setStarCount(starCount);
|
||||||
.mainImage(mainImage)
|
view.setMainImage(mainImage);
|
||||||
.build();
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
Integer id;
|
private Integer id;
|
||||||
OffsetDateTime created;
|
private OffsetDateTime created;
|
||||||
OffsetDateTime modified;
|
private OffsetDateTime modified;
|
||||||
String slug;
|
private String slug;
|
||||||
String title;
|
private String title;
|
||||||
@Nullable Integer preparationTime;
|
private @Nullable Integer preparationTime;
|
||||||
@Nullable Integer cookingTime;
|
private @Nullable Integer cookingTime;
|
||||||
@Nullable Integer totalTime;
|
private @Nullable Integer totalTime;
|
||||||
UserInfoView owner;
|
private UserInfoView owner;
|
||||||
boolean isPublic;
|
private boolean isPublic;
|
||||||
int starCount;
|
private int starCount;
|
||||||
@Nullable ImageView mainImage;
|
private @Nullable ImageView mainImage;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,9 @@
|
|||||||
package app.mealsmadeeasy.api.security;
|
package app.mealsmadeeasy.api.security;
|
||||||
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Value
|
public interface AuthToken {
|
||||||
public class AuthToken {
|
String getToken();
|
||||||
String token;
|
long getLifetime();
|
||||||
long lifetime;
|
LocalDateTime getExpires();
|
||||||
LocalDateTime expires;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,31 @@
|
|||||||
package app.mealsmadeeasy.api.security;
|
package app.mealsmadeeasy.api.security;
|
||||||
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
@Value
|
|
||||||
public class SecurityExceptionView {
|
public class SecurityExceptionView {
|
||||||
|
|
||||||
public enum Action {
|
public enum Action {
|
||||||
LOGIN, REFRESH
|
LOGIN, REFRESH
|
||||||
}
|
}
|
||||||
|
|
||||||
int status;
|
private final int status;
|
||||||
Action action;
|
private final Action action;
|
||||||
String message;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
package app.mealsmadeeasy.api.security;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public final class SimpleAuthToken implements AuthToken {
|
||||||
|
|
||||||
|
private final String token;
|
||||||
|
private final long lifetime;
|
||||||
|
private final LocalDateTime expires;
|
||||||
|
|
||||||
|
public SimpleAuthToken(String token, long lifetime, LocalDateTime expires) {
|
||||||
|
this.token = token;
|
||||||
|
this.lifetime = lifetime;
|
||||||
|
this.expires = expires;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getToken() {
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getLifetime() {
|
||||||
|
return this.lifetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LocalDateTime getExpires() {
|
||||||
|
return this.expires;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ import java.util.Set;
|
|||||||
@Entity(name = "User")
|
@Entity(name = "User")
|
||||||
@Table(name = "\"user\"")
|
@Table(name = "\"user\"")
|
||||||
@Data
|
@Data
|
||||||
public class User implements UserDetails {
|
public final class User implements UserDetails {
|
||||||
|
|
||||||
public static User getDefaultDraft() {
|
public static User getDefaultDraft() {
|
||||||
final var user = new User();
|
final var user = new User();
|
||||||
|
|||||||
@ -4,10 +4,10 @@ import jakarta.persistence.*;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.springframework.security.core.GrantedAuthority;
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
|
||||||
@Entity
|
@Entity(name = "UserGrantedAuthority")
|
||||||
@Table(name = "user_granted_authority")
|
@Table(name = "user_granted_authority")
|
||||||
@Data
|
@Data
|
||||||
public class UserGrantedAuthority implements GrantedAuthority {
|
public final class UserGrantedAuthority implements GrantedAuthority {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
|||||||
@ -2,4 +2,4 @@ package app.mealsmadeeasy.api.user;
|
|||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface UserGrantedAuthorityRepository extends JpaRepository<UserGrantedAuthority, Integer> {}
|
public interface UserGrantedAuthorityRepository extends JpaRepository<UserGrantedAuthority, Long> {}
|
||||||
|
|||||||
@ -1,16 +1,33 @@
|
|||||||
package app.mealsmadeeasy.api.user.view;
|
package app.mealsmadeeasy.api.user.view;
|
||||||
|
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
@Value
|
|
||||||
public class UserInfoView {
|
public class UserInfoView {
|
||||||
|
|
||||||
public static UserInfoView from(User user) {
|
public static UserInfoView from(User user) {
|
||||||
return new UserInfoView(user.getId(), user.getUsername());
|
final UserInfoView userInfoView = new UserInfoView();
|
||||||
|
userInfoView.setId(user.getId());
|
||||||
|
userInfoView.setUsername(user.getUsername());
|
||||||
|
return userInfoView;
|
||||||
}
|
}
|
||||||
|
|
||||||
Integer id;
|
private Integer id;
|
||||||
String username;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,21 @@
|
|||||||
package app.mealsmadeeasy.api.util;
|
package app.mealsmadeeasy.api.util;
|
||||||
|
|
||||||
import lombok.Value;
|
public final class AccessDeniedView {
|
||||||
|
|
||||||
|
private final int statusCode;
|
||||||
|
private final String message;
|
||||||
|
|
||||||
|
public AccessDeniedView(int statusCode, String message) {
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStatusCode() {
|
||||||
|
return this.statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return this.message;
|
||||||
|
}
|
||||||
|
|
||||||
@Value
|
|
||||||
public class AccessDeniedView {
|
|
||||||
int statusCode;
|
|
||||||
String message;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.util;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class MimeTypeService {
|
|
||||||
|
|
||||||
private static final Pattern extensionPattern = Pattern.compile(".+\\.(.+)$");
|
|
||||||
|
|
||||||
private static final String IMAGE_JPEG = "image/jpeg";
|
|
||||||
private static final String IMAGE_PNG = "image/png";
|
|
||||||
private static final String IMAGE_SVG = "image/svg+xml";
|
|
||||||
private static final String IMAGE_WEBP = "image/webp";
|
|
||||||
|
|
||||||
public String getMimeType(String userFilename) {
|
|
||||||
final Matcher m = extensionPattern.matcher(userFilename);
|
|
||||||
if (m.matches()) {
|
|
||||||
final String extension = m.group(1);
|
|
||||||
return switch (extension) {
|
|
||||||
case "jpg", "jpeg" -> IMAGE_JPEG;
|
|
||||||
case "png" -> IMAGE_PNG;
|
|
||||||
case "svg" -> IMAGE_SVG;
|
|
||||||
case "webp" -> IMAGE_WEBP;
|
|
||||||
default -> throw new IllegalArgumentException("Cannot determine mime type for extension: " + extension);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new IllegalArgumentException("Cannot determine mime type for filename: " + userFilename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getExtension(String mimeType) {
|
|
||||||
return switch (mimeType) {
|
|
||||||
case IMAGE_JPEG -> "jpg";
|
|
||||||
case IMAGE_PNG -> "png";
|
|
||||||
case IMAGE_SVG -> "svg";
|
|
||||||
case IMAGE_WEBP -> "webp";
|
|
||||||
default -> throw new IllegalArgumentException(
|
|
||||||
"Cannot determine extension from given mimetype: " + mimeType
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -16,7 +16,6 @@ app.mealsmadeeasy.api.minio.endpoint=http://${MINIO_HOST:localhost}:${MINIO_PORT
|
|||||||
app.mealsmadeeasy.api.minio.accessKey=${MINIO_ROOT_USER}
|
app.mealsmadeeasy.api.minio.accessKey=${MINIO_ROOT_USER}
|
||||||
app.mealsmadeeasy.api.minio.secretKey=${MINIO_ROOT_PASSWORD}
|
app.mealsmadeeasy.api.minio.secretKey=${MINIO_ROOT_PASSWORD}
|
||||||
app.mealsmadeeasy.api.images.bucketName=images
|
app.mealsmadeeasy.api.images.bucketName=images
|
||||||
app.mealsmadeeasy.api.files.bucketName=files
|
|
||||||
|
|
||||||
# AI
|
# AI
|
||||||
spring.ai.vectorstore.pgvector.dimensions=1024
|
spring.ai.vectorstore.pgvector.dimensions=1024
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
CREATE TABLE recipe_draft (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
created TIMESTAMPTZ(6) NOT NULL,
|
|
||||||
modified TIMESTAMPTZ(6),
|
|
||||||
state INT NOT NULL,
|
|
||||||
slug VARCHAR(255),
|
|
||||||
title VARCHAR(255),
|
|
||||||
preparation_time INT,
|
|
||||||
cooking_time INT,
|
|
||||||
total_time INT,
|
|
||||||
raw_text TEXT,
|
|
||||||
owner_id INT NOT NULL,
|
|
||||||
main_image_id INT,
|
|
||||||
inferences JSONB,
|
|
||||||
FOREIGN KEY (owner_id) REFERENCES "user",
|
|
||||||
FOREIGN KEY (main_image_id) REFERENCES image
|
|
||||||
);
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
CREATE TABLE job (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
created TIMESTAMPTZ NOT NULL,
|
|
||||||
modified TIMESTAMPTZ,
|
|
||||||
state VARCHAR(64) NOT NULL,
|
|
||||||
job_key VARCHAR(255) NOT NULL,
|
|
||||||
payload JSONB NOT NULL,
|
|
||||||
attempts INT NOT NULL,
|
|
||||||
max_attempts INT NOT NULL,
|
|
||||||
run_after TIMESTAMPTZ NOT NULL,
|
|
||||||
locked_by VARCHAR(255),
|
|
||||||
locked_at TIMESTAMPTZ,
|
|
||||||
last_error TEXT
|
|
||||||
);
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
CREATE TABLE file (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
created TIMESTAMPTZ NOT NULL,
|
|
||||||
user_filename VARCHAR(255) NOT NULL,
|
|
||||||
mime_type VARCHAR(255) NOT NULL,
|
|
||||||
object_name VARCHAR(255) NOT NULL,
|
|
||||||
owner_id INT NOT NULL,
|
|
||||||
FOREIGN KEY (owner_id) REFERENCES "user"
|
|
||||||
);
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
package app.mealsmadeeasy.api.job;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.Spy;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.springframework.context.ApplicationContext;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import static org.hamcrest.MatcherAssert.assertThat;
|
|
||||||
import static org.hamcrest.Matchers.*;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
public class JobServiceTests {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private JobRepository jobRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private ApplicationContext applicationContext;
|
|
||||||
|
|
||||||
@Spy
|
|
||||||
private ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private JobService jobService;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void whenRunOneJob_callsClaimNext() {
|
|
||||||
when(this.jobRepository.claimNext(anyString())).thenReturn(Optional.empty());
|
|
||||||
this.jobService.runOneJob();
|
|
||||||
verify(this.jobRepository).claimNext(anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void findsJobHandlerAndSavesJobInfo() {
|
|
||||||
final Job testJob = new Job();
|
|
||||||
testJob.setJobKey("TEST_JOB_KEY");
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
final JobHandler<Object> testJobHandler = mock(JobHandler.class);
|
|
||||||
when(testJobHandler.getPayloadType()).thenReturn(Object.class);
|
|
||||||
when(testJobHandler.getJobKey()).thenReturn("TEST_JOB_KEY");
|
|
||||||
|
|
||||||
when(this.jobRepository.claimNext(anyString())).thenReturn(Optional.of(testJob));
|
|
||||||
when(this.applicationContext.getBeansOfType(JobHandler.class)).thenReturn(
|
|
||||||
Map.of("testJobHandler", testJobHandler)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.jobService.runOneJob();
|
|
||||||
|
|
||||||
verify(testJobHandler).handle(testJob, null);
|
|
||||||
verify(this.jobRepository).save(testJob);
|
|
||||||
|
|
||||||
assertThat(testJob.getState(), is(Job.State.DONE));
|
|
||||||
assertThat(testJob.getModified(), is(notNullValue()));
|
|
||||||
assertThat(testJob.getLockedBy(), is(nullValue()));
|
|
||||||
assertThat(testJob.getLockedAt(), is(nullValue()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void retryJobAfterThrowingHandler() {
|
|
||||||
final Job testJob = new Job();
|
|
||||||
testJob.setAttempts(0);
|
|
||||||
testJob.setMaxAttempts(3);
|
|
||||||
testJob.setJobKey("TEST_JOB_KEY");
|
|
||||||
|
|
||||||
final JobHandler<Object> throwingHandler = mock(JobHandler.class);
|
|
||||||
when(throwingHandler.getPayloadType()).thenReturn(Object.class);
|
|
||||||
when(throwingHandler.getJobKey()).thenReturn("TEST_JOB_KEY");
|
|
||||||
doThrow(new RuntimeException("TO THROW")).when(throwingHandler).handle(testJob, null);
|
|
||||||
|
|
||||||
when(this.jobRepository.claimNext(anyString())).thenReturn(Optional.of(testJob));
|
|
||||||
when(this.applicationContext.getBeansOfType(JobHandler.class)).thenReturn(
|
|
||||||
Map.of("testJobHandler", throwingHandler)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.jobService.runOneJob();
|
|
||||||
|
|
||||||
verify(throwingHandler).handle(testJob, null);
|
|
||||||
verify(this.jobRepository).save(testJob);
|
|
||||||
|
|
||||||
assertThat(testJob.getState(), is(Job.State.QUEUED));
|
|
||||||
assertThat(testJob.getAttempts(), is(1));
|
|
||||||
assertThat(testJob.getRunAfter(), is(notNullValue()));
|
|
||||||
assertThat(testJob.getLastError(), is(notNullValue()));
|
|
||||||
assertThat(testJob.getModified(), is(notNullValue()));
|
|
||||||
assertThat(testJob.getLockedBy(), is(nullValue()));
|
|
||||||
assertThat(testJob.getLockedAt(), is(nullValue()));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user