From e877568601a99f32c633aedc8752b68c1b4091de Mon Sep 17 00:00:00 2001 From: robertp1984 Date: Mon, 4 May 2026 19:57:04 +0200 Subject: [PATCH] Improve validation. Fix the environment variables for Docker Compose. --- compose.yaml | 8 +-- .../bedrock/BedrockImageBodyResponse.java | 9 ++- .../bedrock/BedrockImageParser.java | 38 +++++++------ .../bedrock/ImageGenerationException.java | 4 ++ .../bedrock/ImageGenerationService.java | 44 +++++++------- .../springbootimages/images/model/Image.java | 19 +++++++ .../images/model/ImageBuilder.java | 1 - .../model/ImageValidationException.java | 11 ++++ .../service/GenerateImageParamsValidator.java | 18 ++++++ .../images/service/ImageService.java | 56 +++++++++--------- .../images/web/GenerateImageParamsDTO.java | 8 +++ .../springbootimages/images/web/ImageDTO.java | 19 +++++++ .../images/web/ImagesController.java | 57 +++++++++---------- .../GenerateImageParamsConverter.java | 4 +- .../web/converter/ImageDTOConverter.java | 6 +- .../messaging/ImageMessage.java | 14 +++-- .../messaging/ImageMessageFactory.java | 5 +- .../messaging/QueueSender.java | 34 +++++------ .../rest/GlobalExceptionHandler.java | 2 +- .../utils/SHA512Calculator.java | 6 +- .../bedrock/BedrockImageParserTest.java | 14 ++--- .../images/ImageServiceTest.java | 33 +++++++---- .../images/ImagesControllerTest.java | 23 ++++++-- .../messaging/QueueSenderTest.java | 43 +++++++------- .../messaging/ImageMessageListener.java | 15 ++--- .../images/model/ImageMessage.java | 10 ++++ .../messaging/ImageMessageListenerTest.java | 26 ++++----- 27 files changed, 317 insertions(+), 210 deletions(-) create mode 100644 springbootimages/src/main/java/org/softwarecave/springbootimages/images/model/ImageValidationException.java create mode 100644 springbootimages/src/main/java/org/softwarecave/springbootimages/images/service/GenerateImageParamsValidator.java diff --git a/compose.yaml b/compose.yaml index d43c4d4..4041cdb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -98,8 +98,8 @@ services: - mongo - rabbitmq environment: - - SPRING_DATA_MONGODB_URI=mongodb://root:password@mongo:27017/?tls=true - - SPRING_DATA_MONGODB_DATABASE=images + - SPRING_MONGODB_URI=mongodb://root:password@mongo:27017/?tls=true + - SPRING_MONGODB_DATABASE=images - SPRING_RABBITMQ_HOST=rabbitmq - SPRING_RABBITMQ_USERNAME=myuser - SPRING_RABBITMQ_PASSWORD=secret @@ -121,8 +121,8 @@ services: - mongo - rabbitmq environment: - - APP_MONGODB_URI=mongodb://root:password@mongo:27017/?tls=true - - APP_MONGODB_DATABASE=mqevents + - SPRING_MONGODB_URI=mongodb://root:password@mongo:27017/?tls=true + - SPRING_MONGODB_DATABASE=mqevents - SPRING_RABBITMQ_HOST=rabbitmq - SPRING_RABBITMQ_USERNAME=myuser - SPRING_RABBITMQ_PASSWORD=secret diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/BedrockImageBodyResponse.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/BedrockImageBodyResponse.java index fe4dfe7..70229be 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/BedrockImageBodyResponse.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/BedrockImageBodyResponse.java @@ -4,10 +4,15 @@ public record BedrockImageBodyResponse(String[] images, String error, String maskImage) { public byte[] getFirstImageBytes() { - return Base64.getDecoder().decode(images[0]); + if (hasImage()) { + return Base64.getDecoder().decode(images[0]); + } else { + throw new ImageGenerationException("No image was generated"); + } } + public boolean hasImage() { - return images != null && images.length > 0; + return images != null && images.length > 0 && images[0] != null; } } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/BedrockImageParser.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/BedrockImageParser.java index 936eaa2..3ab2176 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/BedrockImageParser.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/BedrockImageParser.java @@ -1,27 +1,35 @@ package org.softwarecave.springbootimages.bedrock; -import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.softwarecave.springbootimages.images.model.Image; import org.softwarecave.springbootimages.images.model.ImageBuilder; import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; -import java.io.IOException; - +@Service @Slf4j +@RequiredArgsConstructor public class BedrockImageParser { - final static String IMAGE_MEDIA_TYPE = MediaType.IMAGE_PNG_VALUE; + static final String IMAGE_MEDIA_TYPE = MediaType.IMAGE_PNG_VALUE; private final static int MAX_FILENAME_LENGTH = 128; - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; - public BedrockImageParser(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; - } + public Image parseResponse(String description, byte[] responseBodyBytes) { + if (responseBodyBytes == null) { + throw new ImageGenerationException("Failed to generate image due to null response"); + } - public Image parseResponse(String description, byte[] responseBodyBytes) throws IOException { - BedrockImageBodyResponse responseObject = objectMapper.readValue(responseBodyBytes, BedrockImageBodyResponse.class); + BedrockImageBodyResponse responseObject; + try { + responseObject = jsonMapper.readValue(responseBodyBytes, BedrockImageBodyResponse.class); + } catch (JacksonException e) { + throw new ImageGenerationException("Failed to generate image due to issue with parsing response body", e); + } if (responseObject != null && responseObject.error() == null && responseObject.hasImage()) { return new ImageBuilder() @@ -39,24 +47,20 @@ public Image parseResponse(String description, byte[] responseBodyBytes) throws } else { log.error("Response from image generator contains no image"); } - throw new ImageGenerationException("Failed to generate image. No image present", null); + throw new ImageGenerationException("Failed to generate image. No image present"); } } private String createShortFilename(String description) { String extension = getShortFilenameExtension(); String baseName = description - .replace(" ", "_") + .replaceAll("[^a-zA-Z0-9._-]", "_") .substring(0, Math.min(MAX_FILENAME_LENGTH - extension.length() - 1, description.length())); return baseName + "." + extension; } private String getShortFilenameExtension() { - if (IMAGE_MEDIA_TYPE.equals(MediaType.IMAGE_PNG_VALUE)) { - return "png"; - } else { - throw new IllegalArgumentException("Unsupported image media type " + IMAGE_MEDIA_TYPE); - } + return "png"; } } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/ImageGenerationException.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/ImageGenerationException.java index a3cd39f..e4db0e9 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/ImageGenerationException.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/ImageGenerationException.java @@ -1,6 +1,10 @@ package org.softwarecave.springbootimages.bedrock; public class ImageGenerationException extends RuntimeException { + public ImageGenerationException(String message) { + super(message); + } + public ImageGenerationException(String message, Throwable cause) { super(message, cause); } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/ImageGenerationService.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/ImageGenerationService.java index 479ec68..6df75b4 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/ImageGenerationService.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/bedrock/ImageGenerationService.java @@ -1,37 +1,34 @@ package org.softwarecave.springbootimages.bedrock; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import lombok.NonNull; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.softwarecave.springbootimages.images.model.Image; import org.softwarecave.springbootimages.images.service.GenerateImageParams; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; -import java.math.BigInteger; -import java.security.SecureRandom; import java.util.Optional; +import java.util.random.RandomGenerator; @Service -@Transactional @Slf4j +@RequiredArgsConstructor public class ImageGenerationService { + public static final long DEFAULT_WIDTH = 1024L; + public static final long DEFAULT_HEIGHT = 768L; + public static final double CONFIG_SCALE = 6.5; private final String IMAGE_GEN_MODEL = "amazon.nova-canvas-v1:0"; - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; + private final BedrockImageParser bedrockImageParser; - public ImageGenerationService(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; - } - - public Image generateImage(@NonNull GenerateImageParams params) { + public Image generateImage(GenerateImageParams params) { try (BedrockRuntimeClient client = createClient()) { String jsonRequest = createRequest(params); @@ -40,29 +37,28 @@ public Image generateImage(@NonNull GenerateImageParams params) { .modelId(IMAGE_GEN_MODEL) .accept(BedrockImageParser.IMAGE_MEDIA_TYPE)); - return new BedrockImageParser(objectMapper).parseResponse(params.getDescription(), response.body().asByteArray()); + return bedrockImageParser.parseResponse(params.getDescription(), response.body().asByteArray()); } catch (Exception e) { - log.error("Failed to generate image {} ", e.getMessage(), e); + log.error("Failed to generate image with description {}", params.getDescription(), e); throw new ImageGenerationException("Could not generate image with description=%s".formatted(params.getDescription()), e); } } + private String createRequest(GenerateImageParams request) { + var seed = RandomGenerator.getDefault().nextInt(100); - private String createRequest(GenerateImageParams request) throws JsonProcessingException { - var seed = new BigInteger(31, new SecureRandom()); - - ObjectNode rootNode = objectMapper.createObjectNode(); + ObjectNode rootNode = jsonMapper.createObjectNode(); rootNode.put("taskType", "TEXT_IMAGE"); rootNode.putObject("textToImageParams") .put("text", request.getDescription()); rootNode.putObject("imageGenerationConfig") - .put("width", Optional.ofNullable(request.getWidth()).orElse(1024L)) - .put("height", Optional.ofNullable(request.getHeight()).orElse(768L)) + .put("width", Optional.ofNullable(request.getWidth()).orElse(DEFAULT_WIDTH)) + .put("height", Optional.ofNullable(request.getHeight()).orElse(DEFAULT_HEIGHT)) .put("quality", "standard") - .put("cfgScale", 6.5) + .put("cfgScale", CONFIG_SCALE) .put("seed", seed); - return objectMapper.writeValueAsString(rootNode); + return jsonMapper.writeValueAsString(rootNode); } private BedrockRuntimeClient createClient() { diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/model/Image.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/model/Image.java index 857b346..7d022ab 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/model/Image.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/model/Image.java @@ -1,5 +1,10 @@ package org.softwarecave.springbootimages.images.model; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -17,10 +22,24 @@ public class Image { @Id @EqualsAndHashCode.Exclude private String id; + + @NotBlank private String originalFilename; + + @NotBlank private String contentType; + + @NotNull + @Size(min = 1) private byte[] bytes; + + @Positive private long size; + + @NotBlank private String sha512; + + @NotNull + @PastOrPresent private Instant createdTime; } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/model/ImageBuilder.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/model/ImageBuilder.java index 563d09a..0381d61 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/model/ImageBuilder.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/model/ImageBuilder.java @@ -18,7 +18,6 @@ public ImageBuilder withOriginalFilename(String originalFilename) { return this; } - public ImageBuilder withContentType(String contentType) { image.setContentType(contentType); return this; diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/model/ImageValidationException.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/model/ImageValidationException.java new file mode 100644 index 0000000..301ba3f --- /dev/null +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/model/ImageValidationException.java @@ -0,0 +1,11 @@ +package org.softwarecave.springbootimages.images.model; + +public class ImageValidationException extends RuntimeException { + public ImageValidationException(String message) { + super(message); + } + + public ImageValidationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/service/GenerateImageParamsValidator.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/service/GenerateImageParamsValidator.java new file mode 100644 index 0000000..9ce3f43 --- /dev/null +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/service/GenerateImageParamsValidator.java @@ -0,0 +1,18 @@ +package org.softwarecave.springbootimages.images.service; + +import org.apache.commons.lang3.StringUtils; +import org.softwarecave.springbootimages.images.model.ImageValidationException; + +public class GenerateImageParamsValidator { + public static void validate(GenerateImageParams params) { + if (params == null || StringUtils.isBlank(params.getDescription())) { + throw new ImageValidationException("Description for the image must be specified"); + } + if (params.getWidth() != null && params.getWidth() <= 0) { + throw new ImageValidationException("Width for the image must be positive"); + } + if (params.getHeight() != null && params.getHeight() <= 0) { + throw new ImageValidationException("Height for the image must be positive"); + } + } +} diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/service/ImageService.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/service/ImageService.java index 09a23c1..c6c4a1a 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/service/ImageService.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/service/ImageService.java @@ -1,55 +1,53 @@ package org.softwarecave.springbootimages.images.service; -import com.fasterxml.jackson.core.JsonProcessingException; -import lombok.NonNull; +import lombok.RequiredArgsConstructor; import org.softwarecave.springbootimages.bedrock.ImageGenerationService; import org.softwarecave.springbootimages.images.model.Image; +import org.softwarecave.springbootimages.images.model.ImageValidationException; import org.softwarecave.springbootimages.images.model.NoSuchImageException; import org.softwarecave.springbootimages.messaging.ImageMessageFactory; import org.softwarecave.springbootimages.messaging.QueueSender; -import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - @Service @Transactional -@Scope("singleton") +@RequiredArgsConstructor public class ImageService { private final ImageRepository imageRepository; private final QueueSender queueSender; private final ImageGenerationService imageGenerationService; - public ImageService(ImageRepository imageRepository, QueueSender queueSender, - ImageGenerationService imageGenerationService) { - this.imageRepository = imageRepository; - this.queueSender = queueSender; - this.imageGenerationService = imageGenerationService; - } - - public void saveImage(@NonNull Image image) throws JsonProcessingException { - imageRepository.save(image); - queueSender.publishImagesSavedMessage(ImageMessageFactory.createImageMessage(image)); + public Image saveImage(Image image) { + if (image == null) { + throw new ImageValidationException("Image must not be null"); + } + Image savedImage = imageRepository.save(image); + queueSender.publishImagesSavedMessage(ImageMessageFactory.createImageMessage(savedImage)); + return savedImage; } - public Optional getImage(@NonNull String id) { - return imageRepository.findById(id); + @Transactional(readOnly = true) + public Image getImage(String id) { + return imageRepository.findById(id) + .orElseThrow(() -> new NoSuchImageException("Image with id %s does not exist".formatted(id))); } - public void deleteImage(@NonNull String id) throws JsonProcessingException { - Optional image = imageRepository.findById(id); - if (image.isPresent()) { - imageRepository.deleteById(id); - queueSender.publishImagesDeletedMessage(ImageMessageFactory.createImageMessage(image.get())); - } else { - throw new NoSuchImageException("Image with id: " + id + " does not exist"); + public void deleteImage(String id) { + if (id == null) { + throw new ImageValidationException("Id of the image must not be null"); } + + Image image = imageRepository.findById(id) + .orElseThrow(() -> new NoSuchImageException("Image with id %s does not exist".formatted(id))); + imageRepository.delete(image); + queueSender.publishImagesDeletedMessage(ImageMessageFactory.createImageMessage(image)); } - public Image generateAndSaveImage(@NonNull GenerateImageParams params) throws JsonProcessingException { - Image image = imageGenerationService.generateImage(params); - saveImage(image); - return image; + public Image generateAndSaveImage(GenerateImageParams params) { + GenerateImageParamsValidator.validate(params); + Image generatedImage = imageGenerationService.generateImage(params); + return saveImage(generatedImage); } + } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/GenerateImageParamsDTO.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/GenerateImageParamsDTO.java index 3032af5..333cbf8 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/GenerateImageParamsDTO.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/GenerateImageParamsDTO.java @@ -1,5 +1,7 @@ package org.softwarecave.springbootimages.images.web; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -8,7 +10,13 @@ @AllArgsConstructor @NoArgsConstructor public class GenerateImageParamsDTO { + + @NotBlank private String description; + + @Positive private Long width; + + @Positive private Long height; } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/ImageDTO.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/ImageDTO.java index fd450bc..7182249 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/ImageDTO.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/ImageDTO.java @@ -1,5 +1,10 @@ package org.softwarecave.springbootimages.images.web; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -13,10 +18,24 @@ public class ImageDTO { @EqualsAndHashCode.Exclude private String id; + + @NotBlank private String originalFilename; + + @NotBlank private String contentType; + + @NotNull + @Size(min = 1) private byte[] bytes; + + @Positive private long size; + + @NotBlank private String sha512; + + @NotNull + @PastOrPresent private Instant createdTime; } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/ImagesController.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/ImagesController.java index 1547938..51dc4e0 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/ImagesController.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/ImagesController.java @@ -1,21 +1,20 @@ package org.softwarecave.springbootimages.images.web; -import com.fasterxml.jackson.core.JsonProcessingException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.NonNull; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.softwarecave.springbootimages.images.model.Image; import org.softwarecave.springbootimages.images.model.ImageBuilder; +import org.softwarecave.springbootimages.images.model.ImageValidationException; import org.softwarecave.springbootimages.images.service.GenerateImageParams; import org.softwarecave.springbootimages.images.service.ImageService; -import org.softwarecave.springbootimages.images.model.NoSuchImageException; import org.softwarecave.springbootimages.images.web.converter.GenerateImageParamsConverter; import org.softwarecave.springbootimages.images.web.converter.ImageDTOConverter; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -28,38 +27,39 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.util.Optional; @RestController -@Transactional @RequestMapping("/api/v1/images") @Tag(name = "Images Controller", description = "Controller to upload, download and list images") @Slf4j +@RequiredArgsConstructor public class ImagesController { private final ImageService imageService; + private final ImageDTOConverter imageDTOConverter; + private final GenerateImageParamsConverter generateImageParamsConverter; - public ImagesController(ImageService imageService) { - this.imageService = imageService; - } - - @PostMapping(value = "/", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) @Operation(summary = "Uploads the image or any other file into the database") - public void uploadImage(@RequestParam("image") @NonNull MultipartFile image) throws IOException { + public void uploadImage(@RequestParam("image") MultipartFile image) throws IOException { log.info("Uploading image {}", image.getOriginalFilename()); - Image imageObject = new ImageBuilder() - .withUUID() - .withOriginalFilename(image.getOriginalFilename()) - .withContentType(image.getContentType()) - .withBytes(image.getBytes()) - .withCurrentDateTime() - .build(); + try { + Image imageObject = new ImageBuilder() + .withUUID() + .withOriginalFilename(image.getOriginalFilename()) + .withContentType(image.getContentType()) + .withBytes(image.getBytes()) + .withCurrentDateTime() + .build(); - imageService.saveImage(imageObject); + Image savedImage = imageService.saveImage(imageObject); - log.info("Image uploaded ID={}", imageObject.getId()); + log.info("Image uploaded ID={}", savedImage.getId()); + } catch (IOException e) { + throw new ImageValidationException("Image contents cannot be read", e); + } } @PostMapping(value = "/generatedImage", @@ -68,8 +68,8 @@ public void uploadImage(@RequestParam("image") @NonNull MultipartFile image) thr @ResponseStatus(HttpStatus.OK) @Operation(summary = "Generates a new image using AI and stores into database", description = "The operation can be accessed by sending request object with description 'sunny day' using post to this sample URI http://localhost:8081/images/generatedImage/ The generated image will be returned in the response.") - public ResponseEntity newGeneratedImage(@RequestBody @NonNull GenerateImageParamsDTO paramsDTO) throws IOException { - GenerateImageParams params = GenerateImageParamsConverter.toRequest(paramsDTO); + public ResponseEntity newGeneratedImage(@RequestBody @Valid GenerateImageParamsDTO paramsDTO) throws IOException { + GenerateImageParams params = generateImageParamsConverter.toRequest(paramsDTO); log.info("Generating a new image with params {}", params); Image imageObject = imageService.generateAndSaveImage(params); @@ -82,20 +82,15 @@ public ResponseEntity newGeneratedImage(@RequestBody @NonNull GenerateIm @GetMapping(value = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE, "application/hal+json"}) @Operation(summary = "Fetches the image or any other file from the database based on its ID") - @ResponseStatus(HttpStatus.OK) public ImageDTO getImage(@PathVariable("id") String id) { - Optional image = imageService.getImage(id); - if (image.isPresent()) { - return ImageDTOConverter.convertToDTO(image.get()); - } else { - throw new NoSuchImageException("No image with ID " + id); - } + Image image = imageService.getImage(id); + return imageDTOConverter.convertToDTO(image); } @DeleteMapping(value = "/{id}") @Operation(summary = "Deletes the image or any other file from the database based on the ID") @ResponseStatus(HttpStatus.NO_CONTENT) - public void deleteImage(@PathVariable("id") String id) throws JsonProcessingException { + public void deleteImage(@PathVariable("id") String id) { imageService.deleteImage(id); } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/converter/GenerateImageParamsConverter.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/converter/GenerateImageParamsConverter.java index 387a75b..00fb704 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/converter/GenerateImageParamsConverter.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/converter/GenerateImageParamsConverter.java @@ -2,9 +2,11 @@ import org.softwarecave.springbootimages.images.service.GenerateImageParams; import org.softwarecave.springbootimages.images.web.GenerateImageParamsDTO; +import org.springframework.stereotype.Component; +@Component public class GenerateImageParamsConverter { - public static GenerateImageParams toRequest(GenerateImageParamsDTO requestDTO) { + public GenerateImageParams toRequest(GenerateImageParamsDTO requestDTO) { return new GenerateImageParams(requestDTO.getDescription(), requestDTO.getWidth(), requestDTO.getHeight()); } } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/converter/ImageDTOConverter.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/converter/ImageDTOConverter.java index 2a673b1..53f1394 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/converter/ImageDTOConverter.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/images/web/converter/ImageDTOConverter.java @@ -2,10 +2,12 @@ import org.softwarecave.springbootimages.images.model.Image; import org.softwarecave.springbootimages.images.web.ImageDTO; +import org.springframework.stereotype.Component; +@Component public class ImageDTOConverter { - public static ImageDTO convertToDTO(Image image){ + public ImageDTO convertToDTO(Image image) { if (image == null) { return null; } @@ -20,7 +22,7 @@ public static ImageDTO convertToDTO(Image image){ ); } - public static Image convertToImage(ImageDTO imageDTO){ + public Image convertToImage(ImageDTO imageDTO) { if (imageDTO == null) { return null; } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/messaging/ImageMessage.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/messaging/ImageMessage.java index abd2fda..fb763c8 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/messaging/ImageMessage.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/messaging/ImageMessage.java @@ -1,8 +1,10 @@ package org.softwarecave.springbootimages.messaging; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; import lombok.AllArgsConstructor; import lombok.Data; -import lombok.NonNull; import java.time.Instant; @@ -10,10 +12,14 @@ @AllArgsConstructor public class ImageMessage { private String id; - @NonNull + + @NotBlank private String originalFilename; - @NonNull + + @NotBlank private String contentType; - @NonNull + + @NotNull + @PastOrPresent private Instant createdTime; } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/messaging/ImageMessageFactory.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/messaging/ImageMessageFactory.java index 45af4b9..ac5d98e 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/messaging/ImageMessageFactory.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/messaging/ImageMessageFactory.java @@ -3,8 +3,11 @@ import lombok.NonNull; import org.softwarecave.springbootimages.images.model.Image; +import java.util.Objects; + public class ImageMessageFactory { - public static ImageMessage createImageMessage(@NonNull Image image) { + public static ImageMessage createImageMessage(Image image) { + Objects.requireNonNull(image, "Image must be not null"); return new ImageMessage(image.getId(), image.getOriginalFilename(), image.getContentType(), image.getCreatedTime()); } } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/messaging/QueueSender.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/messaging/QueueSender.java index db849af..2b2dd34 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/messaging/QueueSender.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/messaging/QueueSender.java @@ -1,42 +1,38 @@ package org.softwarecave.springbootimages.messaging; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.NonNull; -import org.springframework.amqp.core.Message; +import lombok.RequiredArgsConstructor; import org.springframework.amqp.rabbit.core.RabbitTemplate; -import org.springframework.amqp.support.converter.SimpleMessageConverter; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import tools.jackson.databind.json.JsonMapper; + +import java.util.Objects; import static org.softwarecave.springbootimages.messaging.config.MessagingConfig.EXCHANGE_NAME; @Service -@Transactional +@RequiredArgsConstructor public class QueueSender { private static final String IMAGES_SAVED_ROUTING_KEY = "images.saved"; private static final String IMAGES_DELETED_ROUTING_KEY = "images.deleted"; private final RabbitTemplate rabbitTemplate; - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; - public QueueSender(RabbitTemplate rabbitTemplate, ObjectMapper objectMapper) { - this.rabbitTemplate = rabbitTemplate; - this.objectMapper = objectMapper; + public void publishImagesSavedMessage(ImageMessage message) { + publishImagesMessage(message, IMAGES_SAVED_ROUTING_KEY); } - public void publishImagesSavedMessage(@NonNull ImageMessage message) throws JsonProcessingException { - String json = objectMapper.writeValueAsString(message); - - Message jsonMsg = new SimpleMessageConverter().toMessage(json, null); - rabbitTemplate.send(EXCHANGE_NAME, IMAGES_SAVED_ROUTING_KEY, jsonMsg); + public void publishImagesDeletedMessage(ImageMessage message) { + publishImagesMessage(message, IMAGES_DELETED_ROUTING_KEY); } - public void publishImagesDeletedMessage(@NonNull ImageMessage message) throws JsonProcessingException { - String json = objectMapper.writeValueAsString(message); + private void publishImagesMessage(ImageMessage message, String imagesSavedRoutingKey) { + Objects.requireNonNull(message, "Message must not be null"); - Message jsonMsg = new SimpleMessageConverter().toMessage(json, null); - rabbitTemplate.send(EXCHANGE_NAME, IMAGES_DELETED_ROUTING_KEY, jsonMsg); + String json = jsonMapper.writeValueAsString(message); + rabbitTemplate.convertAndSend(EXCHANGE_NAME, imagesSavedRoutingKey, json); } + } diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/rest/GlobalExceptionHandler.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/rest/GlobalExceptionHandler.java index 6606065..774d856 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/rest/GlobalExceptionHandler.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/rest/GlobalExceptionHandler.java @@ -30,7 +30,7 @@ public ResponseEntity handleNoSuchImageException(NoSuchImageException ex } @ExceptionHandler({ImageGenerationException.class}) - public ResponseEntity handleNoSuchImageException(ImageGenerationException ex, WebRequest request) { + public ResponseEntity handleImageGenerationException(ImageGenerationException ex, WebRequest request) { ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); return createResponseEntity(pd, null, HttpStatus.INTERNAL_SERVER_ERROR, request); diff --git a/springbootimages/src/main/java/org/softwarecave/springbootimages/utils/SHA512Calculator.java b/springbootimages/src/main/java/org/softwarecave/springbootimages/utils/SHA512Calculator.java index 3bd08ae..5d63719 100644 --- a/springbootimages/src/main/java/org/softwarecave/springbootimages/utils/SHA512Calculator.java +++ b/springbootimages/src/main/java/org/softwarecave/springbootimages/utils/SHA512Calculator.java @@ -1,16 +1,16 @@ package org.softwarecave.springbootimages.utils; -import lombok.NonNull; - import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; +import java.util.Objects; public class SHA512Calculator { private static final String SHA_512 = "SHA-512"; - public String getHash(@NonNull byte[] bytes) { + public String getHash(byte[] bytes) { + Objects.requireNonNull(bytes, "Bytes must not be null"); try { MessageDigest md = MessageDigest.getInstance(SHA_512); byte[] digest = md.digest(bytes); diff --git a/springbootimages/src/test/java/org/softwarecave/springbootimages/bedrock/BedrockImageParserTest.java b/springbootimages/src/test/java/org/softwarecave/springbootimages/bedrock/BedrockImageParserTest.java index 5b4bf95..e730d70 100644 --- a/springbootimages/src/test/java/org/softwarecave/springbootimages/bedrock/BedrockImageParserTest.java +++ b/springbootimages/src/test/java/org/softwarecave/springbootimages/bedrock/BedrockImageParserTest.java @@ -1,9 +1,9 @@ package org.softwarecave.springbootimages.bedrock; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.softwarecave.springbootimages.images.model.Image; +import tools.jackson.databind.json.JsonMapper; import java.nio.charset.StandardCharsets; @@ -18,8 +18,8 @@ public class BedrockImageParserTest { @BeforeEach public void setUp() { - ObjectMapper objectMapper = new ObjectMapper(); - parser = new BedrockImageParser(objectMapper); + JsonMapper jsonMapper = new JsonMapper(); + parser = new BedrockImageParser(jsonMapper); } @Test @@ -39,16 +39,16 @@ void parseResponse_returnsImageAndPopulatesFields_whenResponseContainsBase64Imag void parseResponse_throwsImageGenerationException_whenResponseJsonIsLiteralNull() { byte[] nullJson = "null".getBytes(StandardCharsets.UTF_8); - assertThrows(org.softwarecave.springbootimages.bedrock.ImageGenerationException.class, () -> parser.parseResponse("desc", nullJson)); + assertThrows(ImageGenerationException.class, () -> parser.parseResponse("desc", nullJson)); } @Test void parseResponse_throwsImageGenerationException_whenImagesArrayIsEmpty() { String json = """ - {"images":[]}" + {"images":[]} """; - assertThrows(org.softwarecave.springbootimages.bedrock.ImageGenerationException.class, () -> + assertThrows(ImageGenerationException.class, () -> parser.parseResponse("desc", json.getBytes(StandardCharsets.UTF_8))); } @@ -56,7 +56,7 @@ void parseResponse_throwsImageGenerationException_whenImagesArrayIsEmpty() { void parseResponse_truncatesOriginalFilename_whenDescriptionExceedsMaxLength() throws Exception { String json = """ - {"images":["SGVsbG8="]}" + {"images":["SGVsbG8="]} """; String longDescription = "a".repeat(200); Image image = parser.parseResponse(longDescription, json.getBytes(StandardCharsets.UTF_8)); diff --git a/springbootimages/src/test/java/org/softwarecave/springbootimages/images/ImageServiceTest.java b/springbootimages/src/test/java/org/softwarecave/springbootimages/images/ImageServiceTest.java index 61f6bba..a3b41f7 100644 --- a/springbootimages/src/test/java/org/softwarecave/springbootimages/images/ImageServiceTest.java +++ b/springbootimages/src/test/java/org/softwarecave/springbootimages/images/ImageServiceTest.java @@ -1,6 +1,5 @@ package org.softwarecave.springbootimages.images; -import com.fasterxml.jackson.core.JsonProcessingException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -56,41 +55,51 @@ public void setUp() { } @Test - void saveImage_savesAndPublishesMessage() throws JsonProcessingException { - + void saveImage_savesAndPublishesMessage() { + // given when(imageRepository.save(sampleImage)).thenReturn(sampleImage); + // when imageService.saveImage(sampleImage); + // then verify(imageRepository).save(sampleImage); verify(queueSender).publishImagesSavedMessage(any()); } @Test void getImage_existingId_returnsOptional() { + // given when(imageRepository.findById("abc")).thenReturn(Optional.of(sampleImage)); - Optional result = imageService.getImage("abc"); + // when + Image result = imageService.getImage("abc"); + // then + assertThat(result).isNotNull().isEqualTo(sampleImage); verify(imageRepository).findById("abc"); - assertThat(result).isPresent().contains(sampleImage); } @Test - void deleteImage_existingId_deletes() throws JsonProcessingException { + void deleteImage_existingId_deletes() { + // given when(imageRepository.findById("id1")).thenReturn(Optional.of(sampleImage)); - doNothing().when(imageRepository).deleteById("id1"); + doNothing().when(imageRepository).delete(sampleImage); + // when imageService.deleteImage("id1"); + // then verify(imageRepository).findById("id1"); - verify(imageRepository).deleteById("id1"); + verify(imageRepository).delete(sampleImage); } @Test void deleteImage_nonExisting_throwsNoSuchImageException() { + // given when(imageRepository.findById("nope")).thenReturn(Optional.empty()); + // when && then assertThrows(NoSuchImageException.class, () -> imageService.deleteImage("nope")); verify(imageRepository).findById("nope"); @@ -98,17 +107,19 @@ void deleteImage_nonExisting_throwsNoSuchImageException() { } @Test - void generateAndSaveImageByDescription_generatesSavesAndReturnsImage() throws JsonProcessingException { + void generateAndSaveImageByDescription_generatesSavesAndReturnsImage() { + // given GenerateImageParams request = new GenerateImageParams("desc", 800L, 600L); when(imageGenerationService.generateImage(request)).thenReturn(sampleImage); + when(imageRepository.save(sampleImage)).thenAnswer(a -> a.getArgument(0)); + //when Image result = imageService.generateAndSaveImage(request); + // then assertThat(result).isSameAs(sampleImage); verify(imageGenerationService).generateImage(request); verify(imageRepository).save(sampleImage); verify(queueSender).publishImagesSavedMessage(any()); - } } - diff --git a/springbootimages/src/test/java/org/softwarecave/springbootimages/images/ImagesControllerTest.java b/springbootimages/src/test/java/org/softwarecave/springbootimages/images/ImagesControllerTest.java index 2b354fa..2f217b8 100644 --- a/springbootimages/src/test/java/org/softwarecave/springbootimages/images/ImagesControllerTest.java +++ b/springbootimages/src/test/java/org/softwarecave/springbootimages/images/ImagesControllerTest.java @@ -1,12 +1,15 @@ package org.softwarecave.springbootimages.images; import org.junit.jupiter.api.Test; +import org.mockito.Answers; import org.mockito.ArgumentCaptor; import org.softwarecave.springbootimages.images.model.Image; import org.softwarecave.springbootimages.images.model.ImageBuilder; import org.softwarecave.springbootimages.images.model.NoSuchImageException; import org.softwarecave.springbootimages.images.service.ImageService; import org.softwarecave.springbootimages.images.web.ImagesController; +import org.softwarecave.springbootimages.images.web.converter.GenerateImageParamsConverter; +import org.softwarecave.springbootimages.images.web.converter.ImageDTOConverter; import org.softwarecave.springbootimages.utils.SHA512Calculator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; @@ -19,6 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; @@ -43,14 +47,23 @@ public class ImagesControllerTest { @MockitoBean private ImageService imageService; + @MockitoBean(answers = Answers.CALLS_REAL_METHODS) + private ImageDTOConverter imageDTOConverter; + + @MockitoBean + private GenerateImageParamsConverter generateImageParamsConverter; + @Test public void testUploadImage_Valid() throws Exception { - doNothing().when(imageService).saveImage(any(Image.class)); + // given + when(imageService.saveImage(any(Image.class))).thenAnswer(a -> a.getArgument(0)); - mockMvc.perform(multipart("/api/v1/images/") + // when + mockMvc.perform(multipart("/api/v1/images") .file(new MockMultipartFile("image", FILENAME1, CONTENT_TYPE, BYTES))) .andExpect(status().isCreated()); + // then ArgumentCaptor imageArgumentCaptor = ArgumentCaptor.forClass(Image.class); verify(imageService).saveImage(imageArgumentCaptor.capture()); Image imageToSave = imageArgumentCaptor.getValue(); @@ -72,7 +85,7 @@ public void testGetImage_ValidId() throws Exception { .withBytes(BYTES) .withCurrentDateTime() .build(); - when(imageService.getImage("5")).thenReturn(Optional.of(image)); + when(imageService.getImage("5")).thenReturn(image); mockMvc.perform(get("/api/v1//images/5") .accept(MediaType.APPLICATION_JSON)) @@ -86,9 +99,9 @@ public void testGetImage_ValidId() throws Exception { @Test public void testGetImage_ImageDoesNotExists() throws Exception { - when(imageService.getImage("5")).thenReturn(Optional.empty()); + when(imageService.getImage("5")).thenThrow(new NoSuchImageException("")); - mockMvc.perform(get("/api/v1//images/5") + mockMvc.perform(get("/api/v1/images/5") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound()); } diff --git a/springbootimages/src/test/java/org/softwarecave/springbootimages/messaging/QueueSenderTest.java b/springbootimages/src/test/java/org/softwarecave/springbootimages/messaging/QueueSenderTest.java index 183ba01..5f38726 100644 --- a/springbootimages/src/test/java/org/softwarecave/springbootimages/messaging/QueueSenderTest.java +++ b/springbootimages/src/test/java/org/softwarecave/springbootimages/messaging/QueueSenderTest.java @@ -1,7 +1,5 @@ package org.softwarecave.springbootimages.messaging; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -10,6 +8,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.amqp.core.Message; import org.springframework.amqp.rabbit.core.RabbitTemplate; +import tools.jackson.databind.json.JsonMapper; import java.nio.charset.StandardCharsets; import java.time.Instant; @@ -30,7 +29,7 @@ public class QueueSenderTest { private RabbitTemplate rabbitTemplate; @Mock - private ObjectMapper objectMapper; + private JsonMapper jsonMapper; @InjectMocks private QueueSender queueSender; @@ -38,24 +37,22 @@ public class QueueSenderTest { @Test void publishImagesSavedMessage_withValidMessage_sendsJsonMessageToExchange() throws Exception { ImageMessage msg = new ImageMessage("id-1", "file.png", "image/png", Instant.now()); - when(objectMapper.writeValueAsString(msg)).thenReturn("{\"id\":\"id-1\"}"); + when(jsonMapper.writeValueAsString(msg)).thenReturn("{\"id\":\"id-1\"}"); queueSender.publishImagesSavedMessage(msg); - ArgumentCaptor captor = ArgumentCaptor.forClass(Message.class); - verify(rabbitTemplate, times(1)).send(anyString(), anyString(), captor.capture()); - Message sent = captor.getValue(); - String body = new String(sent.getBody(), StandardCharsets.UTF_8); - assertEquals("{\"id\":\"id-1\"}", body); + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(rabbitTemplate, times(1)).convertAndSend(anyString(), anyString(), captor.capture()); + String sent = captor.getValue(); + assertEquals("{\"id\":\"id-1\"}", sent); } @Test - void publishImagesSavedMessage_objectMapperThrows_propagatesJsonProcessingExceptionAndDoesNotSend() throws Exception { + void publishImagesSavedMessage_jsonMapperThrows_propagatesRuntimeExceptionAndDoesNotSend() { ImageMessage msg = new ImageMessage("id-2", "file2.png", "image/png", Instant.now()); - when(objectMapper.writeValueAsString(any())).thenThrow(new JsonProcessingException("fail") { - }); + when(jsonMapper.writeValueAsString(any())).thenThrow(new RuntimeException("fail")); - assertThrows(JsonProcessingException.class, () -> queueSender.publishImagesSavedMessage(msg)); + assertThrows(RuntimeException.class, () -> queueSender.publishImagesSavedMessage(msg)); verify(rabbitTemplate, never()).send(anyString(), anyString(), any(Message.class)); } @@ -68,25 +65,23 @@ void publishImagesSavedMessage_nullMessage_throwsNullPointerExceptionAndDoesNotS @Test void publishImagesDeletedMessage_withValidMessage_sendsJsonMessageToExchange() throws Exception { ImageMessage msg = new ImageMessage("id-1", "file.png", "image/png", Instant.now()); - when(objectMapper.writeValueAsString(msg)).thenReturn("{\"id\":\"id-1\"}"); + when(jsonMapper.writeValueAsString(msg)).thenReturn("{\"id\":\"id-1\"}"); queueSender.publishImagesDeletedMessage(msg); - ArgumentCaptor captor = ArgumentCaptor.forClass(Message.class); - verify(rabbitTemplate, times(1)).send(anyString(), anyString(), captor.capture()); - Message sent = captor.getValue(); - String body = new String(sent.getBody(), StandardCharsets.UTF_8); - assertEquals("{\"id\":\"id-1\"}", body); + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(rabbitTemplate, times(1)).convertAndSend(anyString(), anyString(), captor.capture()); + String sent = captor.getValue(); + assertEquals("{\"id\":\"id-1\"}", sent); } @Test - void publishImagesDeletedMessage_objectMapperThrows_propagatesJsonProcessingExceptionAndDoesNotSend() throws Exception { + void publishImagesDeletedMessage_jsonMapperThrows_propagatesRuntimeExceptionAndDoesNotSend() throws Exception { ImageMessage msg = new ImageMessage("id-2", "file2.png", "image/png", Instant.now()); - when(objectMapper.writeValueAsString(any())).thenThrow(new JsonProcessingException("fail") { - }); + when(jsonMapper.writeValueAsString(any())).thenThrow(new RuntimeException("fail")); - assertThrows(JsonProcessingException.class, () -> queueSender.publishImagesDeletedMessage(msg)); - verify(rabbitTemplate, never()).send(anyString(), anyString(), any(Message.class)); + assertThrows(RuntimeException.class, () -> queueSender.publishImagesDeletedMessage(msg)); + verify(rabbitTemplate, never()).convertAndSend(anyString(), anyString(), anyString()); } @Test diff --git a/springbootmqreceiver/src/main/java/org/softwarecave/springbootmqreceiver/images/messaging/ImageMessageListener.java b/springbootmqreceiver/src/main/java/org/softwarecave/springbootmqreceiver/images/messaging/ImageMessageListener.java index b127669..97fc499 100644 --- a/springbootmqreceiver/src/main/java/org/softwarecave/springbootmqreceiver/images/messaging/ImageMessageListener.java +++ b/springbootmqreceiver/src/main/java/org/softwarecave/springbootmqreceiver/images/messaging/ImageMessageListener.java @@ -1,17 +1,15 @@ package org.softwarecave.springbootmqreceiver.images.messaging; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; +import org.softwarecave.springbootmqreceiver.images.config.MessagingConfig; import org.softwarecave.springbootmqreceiver.images.model.ActionType; import org.softwarecave.springbootmqreceiver.images.model.ImageMessage; import org.softwarecave.springbootmqreceiver.images.service.ImageMessageProcessor; -import org.softwarecave.springbootmqreceiver.images.config.MessagingConfig; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import tools.jackson.databind.json.JsonMapper; @Component @Transactional @@ -38,14 +36,13 @@ public void receiveDeletedMessage(@NonNull String messageString) { private ImageMessage readImageMessage(String messageString, ActionType actionType) { try { - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - ImageMessage imageMessage = objectMapper.readValue(messageString, ImageMessage.class); + JsonMapper jsonMapper = new JsonMapper(); + ImageMessage imageMessage = jsonMapper.readValue(messageString, ImageMessage.class); imageMessage.setActionType(actionType); return imageMessage; - } catch (JsonProcessingException e) { + } catch (Exception e) { log.error("Failed to read or process the received message: %s".formatted(e.getMessage()), e); - throw new IllegalArgumentException("Failed to read or process the received message", e); + throw new IllegalArgumentException("The image message must be valid JSON"); } } } diff --git a/springbootmqreceiver/src/main/java/org/softwarecave/springbootmqreceiver/images/model/ImageMessage.java b/springbootmqreceiver/src/main/java/org/softwarecave/springbootmqreceiver/images/model/ImageMessage.java index 48ed65b..8736c3b 100644 --- a/springbootmqreceiver/src/main/java/org/softwarecave/springbootmqreceiver/images/model/ImageMessage.java +++ b/springbootmqreceiver/src/main/java/org/softwarecave/springbootmqreceiver/images/model/ImageMessage.java @@ -1,5 +1,7 @@ package org.softwarecave.springbootmqreceiver.images.model; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -15,8 +17,16 @@ public class ImageMessage { @Id private String id; + + @NotBlank private String originalFilename; + + @NotBlank private String contentType; + + @NotBlank private Instant createdTime; + + @NotNull private ActionType actionType; } diff --git a/springbootmqreceiver/src/test/java/org/softwarecave/springbootmqreceiver/images/messaging/ImageMessageListenerTest.java b/springbootmqreceiver/src/test/java/org/softwarecave/springbootmqreceiver/images/messaging/ImageMessageListenerTest.java index 6e401b9..2756410 100644 --- a/springbootmqreceiver/src/test/java/org/softwarecave/springbootmqreceiver/images/messaging/ImageMessageListenerTest.java +++ b/springbootmqreceiver/src/test/java/org/softwarecave/springbootmqreceiver/images/messaging/ImageMessageListenerTest.java @@ -1,8 +1,5 @@ package org.softwarecave.springbootmqreceiver.images.messaging; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -12,6 +9,7 @@ import org.softwarecave.springbootmqreceiver.images.model.ActionType; import org.softwarecave.springbootmqreceiver.images.model.ImageMessage; import org.softwarecave.springbootmqreceiver.images.service.ImageMessageProcessor; +import tools.jackson.databind.json.JsonMapper; import java.time.Instant; import java.util.UUID; @@ -53,11 +51,11 @@ public void testReceiveSavedMessage_InvalidJson() { } @Test - public void testReceiveSavedMessage_ValidAndComplete() throws JsonProcessingException { + public void testReceiveSavedMessage_ValidAndComplete() { // given ImageMessage sourceImageMessage = new ImageMessage(UUID.randomUUID().toString(), FILENAME1, TEXT_PLAIN, INSTANT, ActionType.SAVE); - String sourceImageMessageJson = getObjectMapper().writeValueAsString(sourceImageMessage); + String sourceImageMessageJson = getJsonMapper().writeValueAsString(sourceImageMessage); doNothing().when(imageMessageProcessor).process(sourceImageMessage); // when @@ -75,11 +73,11 @@ public void testReceiveSavedMessage_ValidAndComplete() throws JsonProcessingExce } @Test - public void testReceiveSavedMessage_ValidAndNotComplete() throws JsonProcessingException { + public void testReceiveSavedMessage_ValidAndNotComplete() { // given ImageMessage sourceImageMessage = new ImageMessage(UUID.randomUUID().toString(), FILENAME1, TEXT_PLAIN, null, ActionType.SAVE); - String sourceImageMessageJson = getObjectMapper().writeValueAsString(sourceImageMessage); + String sourceImageMessageJson = getJsonMapper().writeValueAsString(sourceImageMessage); doNothing().when(imageMessageProcessor).process(sourceImageMessage); // when @@ -97,11 +95,11 @@ public void testReceiveSavedMessage_ValidAndNotComplete() throws JsonProcessingE } @Test - public void testReceiveDeletedMessage_ValidAndComplete() throws JsonProcessingException { + public void testReceiveDeletedMessage_ValidAndComplete() { // given ImageMessage sourceImageMessage = new ImageMessage(UUID.randomUUID().toString(), FILENAME1, TEXT_PLAIN, INSTANT, ActionType.DELETE); - String sourceImageMessageJson = getObjectMapper().writeValueAsString(sourceImageMessage); + String sourceImageMessageJson = getJsonMapper().writeValueAsString(sourceImageMessage); doNothing().when(imageMessageProcessor).process(sourceImageMessage); // when @@ -119,11 +117,11 @@ public void testReceiveDeletedMessage_ValidAndComplete() throws JsonProcessingEx } @Test - public void testReceiveDeletedMessage_ValidAndNotComplete() throws JsonProcessingException { + public void testReceiveDeletedMessage_ValidAndNotComplete() { // given ImageMessage sourceImageMessage = new ImageMessage(UUID.randomUUID().toString(), FILENAME1, TEXT_PLAIN, null, ActionType.DELETE); - String sourceImageMessageJson = getObjectMapper().writeValueAsString(sourceImageMessage); + String sourceImageMessageJson = getJsonMapper().writeValueAsString(sourceImageMessage); doNothing().when(imageMessageProcessor).process(sourceImageMessage); // when @@ -140,9 +138,7 @@ public void testReceiveDeletedMessage_ValidAndNotComplete() throws JsonProcessin .hasFieldOrPropertyWithValue("actionType", ActionType.DELETE); } - private ObjectMapper getObjectMapper() { - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - return objectMapper; + private JsonMapper getJsonMapper() { + return new JsonMapper(); } }