diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java index 07365a0..9bb8dba 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; @@ -21,7 +22,9 @@ import org.testcontainers.utility.DockerImageName; import java.io.IOException; import java.io.InputStream; +import static org.hamcrest.CoreMatchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @Testcontainers @@ -73,7 +76,6 @@ public class ImageControllerTests { owner, USER_FILENAME, hal9000, - "image/svg+xml", 27881L ); } @@ -133,4 +135,39 @@ public class ImageControllerTests { this.doGetImageTestWithViewer(accessToken); } + @Test + @DirtiesContext + public void putImage() throws Exception { + final User owner = this.createTestUser("imageOwner"); + final String accessToken = this.getAccessToken(owner.getUsername()); + try (final InputStream hal9000 = getHal9000()) { + final MockMultipartFile mockMultipartFile = new MockMultipartFile( + "image", "HAL9000.svg", "image/svg+xml", hal9000 + ); + this.mockMvc.perform( + multipart("/images") + .file(mockMultipartFile) + .param("filename", "HAL9000.svg") + .param("alt", "HAL 9000") + .param("caption", "HAL 9000, from 2001: A Space Odyssey") + .param("isPublic", "true") + .header("Authorization", "Bearer " + accessToken) + .with(req -> { + req.setMethod("PUT"); + return req; + }) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.created").exists()) + .andExpect(jsonPath("$.modified").value(nullValue())) + .andExpect(jsonPath("$.filename").value(USER_FILENAME)) + .andExpect(jsonPath("$.mimeType").value("image/svg+xml")) + .andExpect(jsonPath("$.alt").value("HAL 9000")) + .andExpect(jsonPath("$.caption").value("HAL 9000, from 2001: A Space Odyssey")) + .andExpect(jsonPath("$.isPublic").value(true)) + .andExpect(jsonPath("$.owner.username").value("imageOwner")) + .andExpect(jsonPath("$.owner.id").value(owner.getId())); + } + } + } diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/image/S3ImageServiceTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/image/S3ImageServiceTests.java index 0769fb0..453fd19 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/image/S3ImageServiceTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/image/S3ImageServiceTests.java @@ -68,7 +68,6 @@ public class S3ImageServiceTests { owner, USER_FILENAME, hal9000, - "image/svg+xml", 27881L ); } diff --git a/src/main/java/app/mealsmadeeasy/api/DevConfiguration.java b/src/main/java/app/mealsmadeeasy/api/DevConfiguration.java index dbe8fee..a4d5058 100644 --- a/src/main/java/app/mealsmadeeasy/api/DevConfiguration.java +++ b/src/main/java/app/mealsmadeeasy/api/DevConfiguration.java @@ -51,7 +51,6 @@ public class DevConfiguration { testUser, "HAL9000.svg", inputStream, - "image/svg+xml", 27881L ); this.imageService.setPublic(image, testUser, true); diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageController.java b/src/main/java/app/mealsmadeeasy/api/image/ImageController.java index 9b9945a..a352651 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/ImageController.java +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageController.java @@ -1,15 +1,16 @@ package app.mealsmadeeasy.api.image; +import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserService; +import app.mealsmadeeasy.api.user.view.UserInfoView; import org.springframework.core.io.InputStreamResource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.io.InputStream; @@ -18,6 +19,23 @@ import java.io.InputStream; @RequestMapping("/images") public class ImageController { + private static ImageView getView(Image image, User owner) { + final ImageView imageView = new ImageView(); + imageView.setCreated(image.getCreated()); + imageView.setFilename(image.getUserFilename()); + imageView.setMimeType(image.getMimeType()); + imageView.setAlt(image.getAlt()); + imageView.setCaption(image.getCaption()); + imageView.setIsPublic(image.isPublic()); + + final UserInfoView userInfoView = new UserInfoView(); + userInfoView.setId(owner.getId()); + userInfoView.setUsername(owner.getUsername()); + imageView.setOwner(userInfoView); + + return imageView; + } + private final ImageService imageService; private final UserService userService; @@ -40,4 +58,36 @@ public class ImageController { .body(new InputStreamResource(imageInputStream)); } + @PutMapping + public ResponseEntity putImage( + @RequestParam MultipartFile image, + @RequestParam String filename, + @RequestParam(required = false) String alt, + @RequestParam(required = false) String caption, + @RequestParam(required = false) Boolean isPublic, + @AuthenticationPrincipal User principal + ) throws IOException, ImageException { + if (principal == null) { + throw new AccessDeniedException("Must be logged in."); + } + + Image saved = this.imageService.create( + principal, + filename, + image.getInputStream(), + image.getSize() + ); + if (alt != null) { + saved = this.imageService.setAlt(saved, principal, alt); + } + if (caption != null) { + saved = this.imageService.setCaption(saved, principal, caption); + } + if (isPublic != null) { + saved = this.imageService.setPublic(saved, principal, isPublic); + } + + return ResponseEntity.status(201).body(getView(saved, principal)); + } + } diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageService.java b/src/main/java/app/mealsmadeeasy/api/image/ImageService.java index 78847ee..c154abe 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/ImageService.java +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageService.java @@ -9,7 +9,7 @@ import java.util.List; public interface ImageService { - Image create(User owner, String userFilename, InputStream inputStream, String mimeType, long objectSize) + Image create(User owner, String userFilename, InputStream inputStream, long objectSize) throws IOException, ImageException; Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException; diff --git a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java index 0aeae0f..432091a 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java +++ b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java @@ -13,10 +13,14 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service public class S3ImageService implements ImageService { + private static final Pattern extensionPattern = Pattern.compile(".+\\.(.+)$"); + private final S3Manager s3Manager; private final S3ImageRepository imageRepository; private final String imageBucketName; @@ -31,8 +35,25 @@ public class S3ImageService implements ImageService { this.imageBucketName = imageBucketName; } + 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+xml"; + 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+xml" -> "svg"; default -> throw new ImageException( ImageException.Type.UNKNOWN_MIME_TYPE, @@ -42,8 +63,9 @@ public class S3ImageService implements ImageService { } @Override - public Image create(User owner, String userFilename, InputStream inputStream, String mimeType, long objectSize) + public Image create(User owner, String userFilename, InputStream inputStream, long objectSize) throws IOException, ImageException { + final String mimeType = this.getMimeType(userFilename); final String uuid = UUID.randomUUID().toString(); final String extension = this.getExtension(mimeType); final String filename = uuid + "." + extension; diff --git a/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java b/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java new file mode 100644 index 0000000..b9770c8 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java @@ -0,0 +1,83 @@ +package app.mealsmadeeasy.api.image.view; + +import app.mealsmadeeasy.api.user.view.UserInfoView; +import org.jetbrains.annotations.Nullable; + +import java.time.LocalDateTime; + +public class ImageView { + + private LocalDateTime created; + private @Nullable LocalDateTime modified; + private String filename; + private String mimeType; + private @Nullable String alt; + private @Nullable String caption; + private UserInfoView owner; + private boolean isPublic; + + public LocalDateTime getCreated() { + return this.created; + } + + public void setCreated(LocalDateTime created) { + this.created = created; + } + + public @Nullable LocalDateTime getModified() { + return this.modified; + } + + public void setModified(@Nullable LocalDateTime 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 String getAlt() { + return this.alt; + } + + public void setAlt(String alt) { + this.alt = alt; + } + + public String getCaption() { + return this.caption; + } + + public void setCaption(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; + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/user/view/UserInfoView.java b/src/main/java/app/mealsmadeeasy/api/user/view/UserInfoView.java new file mode 100644 index 0000000..5aa5146 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/user/view/UserInfoView.java @@ -0,0 +1,24 @@ +package app.mealsmadeeasy.api.user.view; + +public class UserInfoView { + + private long id; + private String username; + + public long getId() { + return this.id; + } + + public void setId(long id) { + this.id = id; + } + + public String getUsername() { + return this.username; + } + + public void setUsername(String username) { + this.username = username; + } + +}