Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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";
}

}
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ public ImageBuilder withOriginalFilename(String originalFilename) {
return this;
}


public ImageBuilder withContentType(String contentType) {
image.setContentType(contentType);
return this;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Image> 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> 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);
}

}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,7 +10,13 @@
@AllArgsConstructor
@NoArgsConstructor
public class GenerateImageParamsDTO {

@NotBlank
private String description;

@Positive
private Long width;

@Positive
private Long height;
}
Loading