diff --git a/.gitignore b/.gitignore index 2838842..08c6ceb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -# Created by https://www.toptal.com/developers/gitignore/api/osx,vim,node,patch,vuejs,gradle,intellij,certificates,java -# Edit at https://www.toptal.com/developers/gitignore?templates=osx,vim,node,patch,vuejs,gradle,intellij,certificates,java +# Created by https://www.toptal.com/developers/gitignore/api/osx,vim,node,patch,vuejs,gradle,intellij,visualstudiocode,certificates,java +# Edit at https://www.toptal.com/developers/gitignore?templates=osx,vim,node,patch,vuejs,gradle,intellij,visualstudiocode,certificates,java ### Frontebd build artifcats ### app/src/main/resources/META-INF/resources @@ -398,6 +398,9 @@ gradle-app.setting .project # JDT-specific (Eclipse Java Development Tools) .classpath +.factorypath +.settings/ +**/bin/ # Java heap dump *.hprof diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 27fd5d4..9f1b02d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,7 +20,7 @@ val quarkusPlatformVersion: String by project dependencies { errorprone(libs.errorprone.core) errorprone(libs.nullaway) - + implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) implementation(libs.quarkus.arc) @@ -33,6 +33,8 @@ dependencies { implementation(libs.quarkus.rest) implementation(libs.quarkus.rest.jackson) implementation(libs.quarkus.smallrye.jwt) + implementation("io.quarkiverse.amazonservices:quarkus-amazon-s3:3.12.1") + implementation("software.amazon.awssdk:url-connection-client:2.40.13") testImplementation(libs.assertj.core) testImplementation(libs.mockito.junit) diff --git a/app/data/images/spaghetti.jpg b/app/data/images/spaghetti.jpg new file mode 100644 index 0000000..ff00875 Binary files /dev/null and b/app/data/images/spaghetti.jpg differ diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/config/DemoDataSyncService.java b/app/src/main/java/dev/blaauwendraad/recipe_book/config/DemoDataSyncService.java index f423ace..2f62f5e 100644 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/config/DemoDataSyncService.java +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/config/DemoDataSyncService.java @@ -1,7 +1,10 @@ package dev.blaauwendraad.recipe_book.config; +import dev.blaauwendraad.recipe_book.service.ImageService; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; +import java.io.File; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.flywaydb.core.Flyway; import org.jboss.logging.Logger; @@ -9,18 +12,22 @@ @ApplicationScoped public final class DemoDataSyncService { private static final Logger log = Logger.getLogger(DemoDataSyncService.class); + private ImageService imageService; private final String jdbcUrl; private final String username; private final String password; + @Inject public DemoDataSyncService( @ConfigProperty(name = "quarkus.datasource.jdbc.url") String jdbcUrl, @ConfigProperty(name = "quarkus.datasource.username") String username, - @ConfigProperty(name = "quarkus.datasource.password") String password) { + @ConfigProperty(name = "quarkus.datasource.password") String password, + ImageService imageService) { this.jdbcUrl = jdbcUrl; this.username = username; this.password = password; + this.imageService = imageService; } @Transactional @@ -46,4 +53,10 @@ void insertDemoData() { throw new RuntimeException("Demo data migration failed", e); } } + + void insertDemoImage() { + File imageFile = new File("data/images/spaghetti.jpg"); + imageService.putImage(imageFile, "spaghetti.jpg"); + log.info("Inserted demo image into Garage/S3"); + } } diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/config/ReferenceDataSyncService.java b/app/src/main/java/dev/blaauwendraad/recipe_book/config/ReferenceDataSyncService.java index 377cd4f..1eeae5d 100644 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/config/ReferenceDataSyncService.java +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/config/ReferenceDataSyncService.java @@ -32,6 +32,7 @@ public void syncReferenceData(@Observes StartupEvent startupEvent) { validateRoles(); if (profile.isPresent() && "dev".equals(profile.get())) { demoDataSyncService.insertDemoData(); + demoDataSyncService.insertDemoImage(); } } diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/data/model/RecipeEntity.java b/app/src/main/java/dev/blaauwendraad/recipe_book/data/model/RecipeEntity.java index 4671147..159d952 100644 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/data/model/RecipeEntity.java +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/data/model/RecipeEntity.java @@ -35,6 +35,10 @@ public class RecipeEntity extends PanacheEntityBase { @Nullable public String description; + @Column(name = "image_name") + @Nullable + public String imageName; + @Column(name = "num_servings") @SuppressWarnings("NullAway.Init") public Integer numServings; diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/repository/RecipeRepository.java b/app/src/main/java/dev/blaauwendraad/recipe_book/repository/RecipeRepository.java index 206ce93..8460ffd 100644 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/repository/RecipeRepository.java +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/repository/RecipeRepository.java @@ -34,6 +34,7 @@ public Long persistRecipeEntity( UserAccountEntity userAccountEntity, String title, @Nullable String description, + @Nullable String imageName, Integer numServings, PreparationTime preparationTime, List ingredients, @@ -49,6 +50,7 @@ public Long persistRecipeEntity( var recipeEntity = existingRecipeEntity != null ? existingRecipeEntity : new RecipeEntity(); recipeEntity.title = title; recipeEntity.description = description; + recipeEntity.imageName = imageName; recipeEntity.numServings = numServings; recipeEntity.preparationTime = preparationTime; diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/service/ImageService.java b/app/src/main/java/dev/blaauwendraad/recipe_book/service/ImageService.java new file mode 100644 index 0000000..be88f83 --- /dev/null +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/service/ImageService.java @@ -0,0 +1,58 @@ +package dev.blaauwendraad.recipe_book.service; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityNotFoundException; +import java.io.File; +import java.util.UUID; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@ApplicationScoped +public class ImageService { + private static final String BUCKET_NAME = "images"; + private S3Client s3Client; + + @Inject + public ImageService(S3Client s3Client) { + this.s3Client = s3Client; + } + + public String putImage(File image) { + String key = image.getName() + "_" + UUID.randomUUID().toString(); + putImage(image, key); + return key; + } + + public void putImage(File image, String imageKey) { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(BUCKET_NAME) + .key(imageKey) + .contentType("image/jpeg") + .build(); + s3Client.putObject(request, RequestBody.fromFile(image)); + } + + public byte[] getImage(String imageKey) { + try { + GetObjectRequest getObjectRequest = + GetObjectRequest.builder().bucket(BUCKET_NAME).key(imageKey).build(); + ResponseBytes objectBytes = s3Client.getObjectAsBytes(getObjectRequest); + return objectBytes.asByteArray(); + } catch (NoSuchKeyException e) { + throw new EntityNotFoundException("Image not found for imageKey: " + imageKey, e); + } + } + + public void deleteImage(String imageKey) { + DeleteObjectRequest deleteObjectRequest = + DeleteObjectRequest.builder().bucket(BUCKET_NAME).key(imageKey).build(); + s3Client.deleteObject(deleteObjectRequest); + } +} diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/service/RecipeService.java b/app/src/main/java/dev/blaauwendraad/recipe_book/service/RecipeService.java index 178f4ea..3578d19 100644 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/service/RecipeService.java +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/service/RecipeService.java @@ -4,6 +4,9 @@ import dev.blaauwendraad.recipe_book.data.model.UserAccountEntity; import dev.blaauwendraad.recipe_book.repository.RecipeRepository; import dev.blaauwendraad.recipe_book.repository.UserRepository; +import dev.blaauwendraad.recipe_book.service.exception.EntityNotFoundException; +import dev.blaauwendraad.recipe_book.service.exception.UserAuthenticationException; +import dev.blaauwendraad.recipe_book.service.exception.UserAuthorizationException; import dev.blaauwendraad.recipe_book.service.model.Author; import dev.blaauwendraad.recipe_book.service.model.Ingredient; import dev.blaauwendraad.recipe_book.service.model.PreparationStep; @@ -15,6 +18,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; +import java.io.File; import java.util.List; import java.util.stream.Collectors; @@ -22,11 +26,13 @@ public class RecipeService { private final UserRepository userRepository; private final RecipeRepository recipeRepository; + private final ImageService imageService; @Inject - public RecipeService(UserRepository userRepository, RecipeRepository recipeRepository) { + public RecipeService(UserRepository userRepository, RecipeRepository recipeRepository, ImageService imageService) { this.userRepository = userRepository; this.recipeRepository = recipeRepository; + this.imageService = imageService; } public List getAllRecipeSummaries( @@ -62,21 +68,22 @@ private RecipeSummary toRecipeSummary(RecipeEntity recipeEntity) { recipeEntity.id, recipeEntity.title, recipeEntity.description, + recipeEntity.imageName, recipeEntity.numServings, recipeEntity.preparationTime, recipeEntity.author == null ? null : new Author(recipeEntity.author.id, recipeEntity.author.username)); } - @Nullable - public Recipe getRecipeById(Long recipeId) { + public Recipe getRecipe(Long recipeId) throws EntityNotFoundException { RecipeEntity recipeEntity = recipeRepository.findById(recipeId); if (recipeEntity == null) { - return null; + throw new EntityNotFoundException("Recipe with recipeId " + recipeId + " does not exist"); } return new Recipe( recipeEntity.id, recipeEntity.title, recipeEntity.description, + recipeEntity.imageName, recipeEntity.numServings, recipeEntity.preparationTime, recipeEntity.author == null ? null : new Author(recipeEntity.author.id, recipeEntity.author.username), @@ -88,6 +95,18 @@ public Recipe getRecipeById(Long recipeId) { .collect(Collectors.toList())); } + public byte[] getRecipeImage(Long recipeId) throws EntityNotFoundException { + Recipe recipe = getRecipe(recipeId); + if (recipe.imageName() == null) { + throw new EntityNotFoundException("Recipe with recipeId: " + recipeId + " has no image"); + } + byte[] imageFile = imageService.getImage(recipe.imageName()); + if (imageFile == null) { + throw new EntityNotFoundException("Image not found for recipeId: " + recipeId); + } + return imageFile; + } + @Transactional public Long createRecipe( String title, @@ -96,16 +115,23 @@ public Long createRecipe( PreparationTime preparationTime, Long userId, List ingredients, - List preparationSteps) { + List preparationSteps, + @Nullable File recipeImage) + throws UserAuthenticationException { UserAccountEntity userAccountEntity = userRepository.findById(userId); if (userAccountEntity == null) { - throw new IllegalArgumentException("Author with userId " + userId + " does not exist"); + throw new UserAuthenticationException("User with userId " + userId + " does not exist"); + } + String imageName = null; + if (recipeImage != null) { + imageName = imageService.putImage(recipeImage); } return recipeRepository.persistRecipeEntity( null, userAccountEntity, title, description, + imageName, numServings, preparationTime, ingredients, @@ -113,19 +139,20 @@ public Long createRecipe( } @Transactional - public void deleteRecipe(Long recipeId, Long userId) { + public void deleteRecipe(Long recipeId, Long userId) + throws UserAuthorizationException, UserAuthenticationException, EntityNotFoundException { UserAccountEntity userAccount = userRepository.findById(userId); if (userAccount == null) { - throw new IllegalArgumentException("User with userId " + userId + " does not exist"); + throw new UserAuthenticationException("User with userId " + userId + " does not exist"); } - RecipeEntity recipeEntity = RecipeEntity.findById(recipeId); - if (recipeEntity == null) { - throw new IllegalArgumentException("Recipe with recipeId " + recipeId + " does not exist"); - } - if (recipeEntity.author == null || !recipeEntity.author.id.equals(userId)) { - throw new IllegalArgumentException( + Recipe recipe = getRecipe(recipeId); + if (recipe.author() == null || !recipe.author().id().equals(userId)) { + throw new UserAuthorizationException( "User with userId " + userId + " is not the author of the recipe with recipeId " + recipeId); } + if (recipe.imageName() != null) { + imageService.deleteImage(recipe.imageName()); + } recipeRepository.deleteById(recipeId); } @@ -138,24 +165,34 @@ public void updateRecipe( PreparationTime preparationTime, Long userId, List ingredients, - List preparationSteps) { + List preparationSteps, + @Nullable File recipeImage) + throws UserAuthorizationException, UserAuthenticationException, EntityNotFoundException { UserAccountEntity userAccount = userRepository.findById(userId); if (userAccount == null) { - throw new IllegalArgumentException("User with userId " + userId + " does not exist"); + throw new UserAuthenticationException("User with userId " + userId + " does not exist"); } RecipeEntity existingRecipeEntity = RecipeEntity.findById(recipeId); if (existingRecipeEntity == null) { - throw new IllegalArgumentException("Recipe with recipeId " + recipeId + " does not exist"); + throw new EntityNotFoundException("Recipe with recipeId " + recipeId + " does not exist"); } if (existingRecipeEntity.author == null || !existingRecipeEntity.author.id.equals(userId)) { - throw new IllegalArgumentException( + throw new UserAuthorizationException( "User with userId " + userId + " is not the author of the recipe with recipeId " + recipeId); } + if (existingRecipeEntity.imageName != null) { + imageService.deleteImage(existingRecipeEntity.imageName); + } + String imageName = null; + if (recipeImage != null) { + imageName = imageService.putImage(recipeImage); + } recipeRepository.persistRecipeEntity( existingRecipeEntity, existingRecipeEntity.author, title, description, + imageName, numServings, preparationTime, ingredients, diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/service/UserService.java b/app/src/main/java/dev/blaauwendraad/recipe_book/service/UserService.java index 1d45100..ffd9b1a 100644 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/service/UserService.java +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/service/UserService.java @@ -2,13 +2,13 @@ import dev.blaauwendraad.recipe_book.data.model.UserAccountEntity; import dev.blaauwendraad.recipe_book.repository.UserRepository; +import dev.blaauwendraad.recipe_book.service.exception.EntityNotFoundException; import dev.blaauwendraad.recipe_book.service.exception.UserAuthenticationException; import dev.blaauwendraad.recipe_book.service.model.UserAccount; import io.quarkus.elytron.security.common.BcryptUtil; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.NotFoundException; @ApplicationScoped public class UserService { @@ -19,19 +19,20 @@ public UserService(UserRepository userRepository) { this.userRepository = userRepository; } - public UserAccount getUser(Long userId) { + public UserAccount getUser(Long userId) throws EntityNotFoundException { UserAccountEntity userAccountEntity = userRepository.findById(userId); if (userAccountEntity == null) { - throw new NotFoundException("User with given userId does not exist"); + throw new EntityNotFoundException("User with given userId does not exist"); } return UserAccountConverter.toUserAccount(userAccountEntity); } @Transactional - public void updateEmail(Long userId, String newEmail, String currentPassword) throws UserAuthenticationException { + public void updateEmail(Long userId, String newEmail, String currentPassword) + throws UserAuthenticationException, EntityNotFoundException { UserAccountEntity userAccountEntity = userRepository.findById(userId); if (userAccountEntity == null) { - throw new NotFoundException("User with given userId does not exist"); + throw new EntityNotFoundException("User with given userId does not exist"); } if (!BcryptUtil.matches(currentPassword, userAccountEntity.passwordHash)) { throw new UserAuthenticationException("Invalid password provided."); @@ -46,10 +47,10 @@ public void updateEmail(Long userId, String newEmail, String currentPassword) th @Transactional public void updatePassword(Long userId, String currentPassword, String newPassword) - throws UserAuthenticationException { + throws UserAuthenticationException, EntityNotFoundException { UserAccountEntity userAccountEntity = userRepository.findById(userId); if (userAccountEntity == null) { - throw new NotFoundException("User with given userId does not exist"); + throw new EntityNotFoundException("User with given userId does not exist"); } if (!BcryptUtil.matches(currentPassword, userAccountEntity.passwordHash)) { throw new UserAuthenticationException("Current password is incorrect."); diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/service/exception/EntityNotFoundException.java b/app/src/main/java/dev/blaauwendraad/recipe_book/service/exception/EntityNotFoundException.java new file mode 100644 index 0000000..83d1feb --- /dev/null +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/service/exception/EntityNotFoundException.java @@ -0,0 +1,8 @@ +package dev.blaauwendraad.recipe_book.service.exception; + +public class EntityNotFoundException extends DetailedMessageException { + + public EntityNotFoundException(String detailMessage) { + super("Entity not found.", detailMessage); + } +} diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/service/exception/EntityNotFoundExceptionMapper.java b/app/src/main/java/dev/blaauwendraad/recipe_book/service/exception/EntityNotFoundExceptionMapper.java new file mode 100644 index 0000000..21f7848 --- /dev/null +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/service/exception/EntityNotFoundExceptionMapper.java @@ -0,0 +1,15 @@ +package dev.blaauwendraad.recipe_book.service.exception; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +@Provider +public class EntityNotFoundExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(EntityNotFoundException exception) { + return Response.status(Response.Status.NOT_FOUND) + .entity(exception.getMessage()) + .build(); + } +} diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/service/exception/UserAuthorizationException.java b/app/src/main/java/dev/blaauwendraad/recipe_book/service/exception/UserAuthorizationException.java new file mode 100644 index 0000000..61716f9 --- /dev/null +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/service/exception/UserAuthorizationException.java @@ -0,0 +1,8 @@ +package dev.blaauwendraad.recipe_book.service.exception; + +public class UserAuthorizationException extends DetailedMessageException { + + public UserAuthorizationException(String detailMessage) { + super("Failed to authorize user.", detailMessage); + } +} diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/service/exception/UserAuthorizationExceptionMapper.java b/app/src/main/java/dev/blaauwendraad/recipe_book/service/exception/UserAuthorizationExceptionMapper.java new file mode 100644 index 0000000..188bea6 --- /dev/null +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/service/exception/UserAuthorizationExceptionMapper.java @@ -0,0 +1,29 @@ +package dev.blaauwendraad.recipe_book.service.exception; + +import dev.blaauwendraad.recipe_book.web.exception.ErrorResponse; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +@Provider +public class UserAuthorizationExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(UserAuthorizationException exception) { + var errorResponse = toErrorResponse(exception); + return Response.status(errorResponse.httpStatusCode()) + .type(MediaType.APPLICATION_JSON_TYPE) + .entity(errorResponse) + .build(); + } + + private static ErrorResponse toErrorResponse(UserAuthorizationException exception) { + return new ErrorResponse( + exception.getMessage() != null + ? exception.getMessage() + : "An unexpected error occurred while trying to authorize the user.", + exception.getDetailMessage(), + Response.Status.BAD_REQUEST.getStatusCode()); + } +} diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/service/model/Recipe.java b/app/src/main/java/dev/blaauwendraad/recipe_book/service/model/Recipe.java index a041cc6..31f803a 100644 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/service/model/Recipe.java +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/service/model/Recipe.java @@ -7,6 +7,7 @@ public record Recipe( Long id, String title, @Nullable String description, + @Nullable String imageName, Integer numServings, PreparationTime preparationTime, @Nullable Author author, diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/service/model/RecipeSummary.java b/app/src/main/java/dev/blaauwendraad/recipe_book/service/model/RecipeSummary.java index 5fef468..de622e6 100644 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/service/model/RecipeSummary.java +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/service/model/RecipeSummary.java @@ -6,6 +6,7 @@ public record RecipeSummary( Long id, String title, @Nullable String description, + @Nullable String imageName, Integer numServings, PreparationTime preparationTime, @Nullable Author author) {} diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/web/RecipeResource.java b/app/src/main/java/dev/blaauwendraad/recipe_book/web/RecipeResource.java index 588abfb..3fdbc34 100644 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/web/RecipeResource.java +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/web/RecipeResource.java @@ -1,6 +1,9 @@ package dev.blaauwendraad.recipe_book.web; import dev.blaauwendraad.recipe_book.service.RecipeService; +import dev.blaauwendraad.recipe_book.service.exception.EntityNotFoundException; +import dev.blaauwendraad.recipe_book.service.exception.UserAuthenticationException; +import dev.blaauwendraad.recipe_book.service.exception.UserAuthorizationException; import dev.blaauwendraad.recipe_book.service.model.Ingredient; import dev.blaauwendraad.recipe_book.service.model.PreparationStep; import dev.blaauwendraad.recipe_book.service.model.Recipe; @@ -8,12 +11,12 @@ import dev.blaauwendraad.recipe_book.web.model.PreparationStepDto; import dev.blaauwendraad.recipe_book.web.model.RecipeAuthorDto; import dev.blaauwendraad.recipe_book.web.model.RecipeDto; -import dev.blaauwendraad.recipe_book.web.model.RecipeResponse; import dev.blaauwendraad.recipe_book.web.model.RecipeSummariesFilter; import dev.blaauwendraad.recipe_book.web.model.RecipeSummariesResponse; import dev.blaauwendraad.recipe_book.web.model.RecipeSummaryDto; import dev.blaauwendraad.recipe_book.web.model.SaveRecipeRequestDto; import dev.blaauwendraad.recipe_book.web.model.SaveRecipeResponseDto; +import jakarta.annotation.Nullable; import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.RolesAllowed; import jakarta.enterprise.context.ApplicationScoped; @@ -24,7 +27,6 @@ import jakarta.ws.rs.DELETE; import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; -import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; @@ -32,7 +34,10 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import java.io.File; import org.eclipse.microprofile.jwt.JsonWebToken; +import org.jboss.resteasy.reactive.PartType; +import org.jboss.resteasy.reactive.RestForm; @ApplicationScoped @Path("/api/recipes") @@ -69,6 +74,7 @@ public RecipeSummariesResponse getRecipeSummaries(@PathParam("filter") RecipeSum recipeSummary.id(), recipeSummary.title(), recipeSummary.description(), + recipeSummary.imageName(), recipeSummary.numServings(), recipeSummary.preparationTime(), recipeSummary.author() == null @@ -83,16 +89,14 @@ public RecipeSummariesResponse getRecipeSummaries(@PathParam("filter") RecipeSum @Path("/{recipeId}") @PermitAll @Produces(MediaType.APPLICATION_JSON) - public RecipeResponse getRecipe(@PathParam("recipeId") Long id) { - Recipe recipe = recipeService.getRecipeById(id); - if (recipe == null) { - throw new NotFoundException("Recipe not found with recipeId: " + id); - } + public RecipeDto getRecipe(@PathParam("recipeId") Long id) throws EntityNotFoundException { + Recipe recipe = recipeService.getRecipe(id); - return new RecipeResponse(new RecipeDto( + return new RecipeDto( recipe.id(), recipe.title(), recipe.description(), + recipe.imageName(), recipe.numServings(), recipe.preparationTime(), recipe.author() == null @@ -104,14 +108,26 @@ public RecipeResponse getRecipe(@PathParam("recipeId") Long id) { .toList(), recipe.preparationSteps().stream() .map(step -> new PreparationStepDto(step.description())) - .toList())); + .toList()); + } + + @GET + @Path("/{recipeId}/image") + @PermitAll + @Produces("image/jpeg") + public Response getRecipeImage(@PathParam("recipeId") Long id) throws EntityNotFoundException { + byte[] imageFile = recipeService.getRecipeImage(id); + return Response.ok(imageFile).build(); } @POST @Produces(MediaType.APPLICATION_JSON) - @Consumes(MediaType.APPLICATION_JSON) + @Consumes(MediaType.MULTIPART_FORM_DATA) @RolesAllowed({"admin", "user"}) - public SaveRecipeResponseDto createRecipe(@NotNull @Valid SaveRecipeRequestDto newRecipe) { + public SaveRecipeResponseDto createRecipe( + @RestForm("newRecipe") @PartType(MediaType.APPLICATION_JSON) @NotNull @Valid SaveRecipeRequestDto newRecipe, + @RestForm("recipeImage") @Nullable File recipeImage) + throws UserAuthenticationException { Long recipeId = recipeService.createRecipe( newRecipe.title(), newRecipe.description(), @@ -123,17 +139,22 @@ public SaveRecipeResponseDto createRecipe(@NotNull @Valid SaveRecipeRequestDto n .toList(), newRecipe.preparationSteps().stream() .map(stepDto -> new PreparationStep(stepDto.description())) - .toList()); + .toList(), + recipeImage); return new SaveRecipeResponseDto(recipeId); } @PUT @Produces(MediaType.APPLICATION_JSON) - @Consumes(MediaType.APPLICATION_JSON) + @Consumes(MediaType.MULTIPART_FORM_DATA) @Path("/{recipeId}") @RolesAllowed({"admin", "user"}) public SaveRecipeResponseDto updateRecipe( - @PathParam("recipeId") Long recipeId, @NotNull @Valid SaveRecipeRequestDto updatedRecipe) { + @PathParam("recipeId") Long recipeId, + @RestForm("newRecipe") @PartType(MediaType.APPLICATION_JSON) @NotNull @Valid + SaveRecipeRequestDto updatedRecipe, + @RestForm("recipeImage") @Nullable File recipeImage) + throws UserAuthorizationException, UserAuthenticationException, EntityNotFoundException { recipeService.updateRecipe( recipeId, updatedRecipe.title(), @@ -146,7 +167,8 @@ public SaveRecipeResponseDto updateRecipe( .toList(), updatedRecipe.preparationSteps().stream() .map(stepDto -> new PreparationStep(stepDto.description())) - .toList()); + .toList(), + recipeImage); return new SaveRecipeResponseDto(recipeId); } @@ -155,7 +177,8 @@ public SaveRecipeResponseDto updateRecipe( @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) @RolesAllowed({"admin", "user"}) - public Response deleteRecipe(@PathParam("recipeId") Long recipeId) { + public Response deleteRecipe(@PathParam("recipeId") Long recipeId) + throws UserAuthorizationException, UserAuthenticationException, EntityNotFoundException { recipeService.deleteRecipe(recipeId, Long.valueOf(jwt.getName())); return Response.noContent().build(); } diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/web/UserResource.java b/app/src/main/java/dev/blaauwendraad/recipe_book/web/UserResource.java index 9e8c8ed..eadd669 100644 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/web/UserResource.java +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/web/UserResource.java @@ -1,6 +1,7 @@ package dev.blaauwendraad.recipe_book.web; import dev.blaauwendraad.recipe_book.service.UserService; +import dev.blaauwendraad.recipe_book.service.exception.EntityNotFoundException; import dev.blaauwendraad.recipe_book.service.exception.UserAuthenticationException; import dev.blaauwendraad.recipe_book.service.model.UserAccount; import dev.blaauwendraad.recipe_book.web.model.UpdateEmailRequest; @@ -38,7 +39,7 @@ public UserResource(UserService userService) { @GET @Produces(MediaType.APPLICATION_JSON) @RolesAllowed({"admin", "user"}) - public UserAccountDto getUser(@PathParam("userId") Long userId) { + public UserAccountDto getUser(@PathParam("userId") Long userId) throws EntityNotFoundException { if (!Long.valueOf(jwt.getName()).equals(userId)) { throw new ForbiddenException("Not allowed to get other user"); } @@ -52,7 +53,7 @@ public UserAccountDto getUser(@PathParam("userId") Long userId) { @Path("/email") @RolesAllowed({"admin", "user"}) public Response updateEmail(@PathParam("userId") Long userId, @Valid @NotNull UpdateEmailRequest request) - throws UserAuthenticationException { + throws UserAuthenticationException, EntityNotFoundException { if (!Long.valueOf(jwt.getName()).equals(userId)) { throw new ForbiddenException("Not allowed to update e-mail of other user"); } @@ -66,7 +67,7 @@ public Response updateEmail(@PathParam("userId") Long userId, @Valid @NotNull Up @Path("/password") @RolesAllowed({"admin", "user"}) public Response updatePassword(@PathParam("userId") Long userId, @Valid @NotNull UpdatePasswordRequest request) - throws UserAuthenticationException { + throws UserAuthenticationException, EntityNotFoundException { if (!Long.valueOf(jwt.getName()).equals(userId)) { throw new ForbiddenException("Not allowed to update password of other user"); } diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/web/model/RecipeDto.java b/app/src/main/java/dev/blaauwendraad/recipe_book/web/model/RecipeDto.java index 6f6f360..d081134 100644 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/web/model/RecipeDto.java +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/web/model/RecipeDto.java @@ -8,6 +8,7 @@ public record RecipeDto( Long id, String title, @Nullable String description, + @Nullable String imageName, Integer numServings, PreparationTime preparationTime, @Nullable RecipeAuthorDto author, diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/web/model/RecipeResponse.java b/app/src/main/java/dev/blaauwendraad/recipe_book/web/model/RecipeResponse.java deleted file mode 100644 index fb6d2ea..0000000 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/web/model/RecipeResponse.java +++ /dev/null @@ -1,3 +0,0 @@ -package dev.blaauwendraad.recipe_book.web.model; - -public record RecipeResponse(RecipeDto recipe) {} diff --git a/app/src/main/java/dev/blaauwendraad/recipe_book/web/model/RecipeSummaryDto.java b/app/src/main/java/dev/blaauwendraad/recipe_book/web/model/RecipeSummaryDto.java index c53a5a2..40542c0 100644 --- a/app/src/main/java/dev/blaauwendraad/recipe_book/web/model/RecipeSummaryDto.java +++ b/app/src/main/java/dev/blaauwendraad/recipe_book/web/model/RecipeSummaryDto.java @@ -7,6 +7,7 @@ public record RecipeSummaryDto( Long id, String title, @Nullable String description, + @Nullable String imageName, Integer numServings, PreparationTime preparationTime, @Nullable RecipeAuthorDto author) {} diff --git a/app/src/main/resources/application-dev.properties b/app/src/main/resources/application-dev.properties index 0130de0..d1cae58 100644 --- a/app/src/main/resources/application-dev.properties +++ b/app/src/main/resources/application-dev.properties @@ -11,4 +11,13 @@ quarkus.flyway.locations=db/migration quarkus.http.cors.origins=* mp.jwt.verify.publickey.location=classpath:local-dev-keys/publicKey.pem -smallrye.jwt.sign.key.location=classpath:local-dev-keys/privateKey.pem \ No newline at end of file +smallrye.jwt.sign.key.location=classpath:local-dev-keys/privateKey.pem + +# S3 configuration for Garage for local development +quarkus.s3.endpoint-override=http://localhost:3900 +quarkus.s3.path-style-access=true +quarkus.s3.chunked-encoding=false +quarkus.s3.aws.region=garage +quarkus.s3.aws.credentials.type=static +quarkus.s3.aws.credentials.static-provider.access-key-id=${S3_ACCESS_KEY_ID} +quarkus.s3.aws.credentials.static-provider.secret-access-key=${S3_ACCESS_KEY_SECRET} \ No newline at end of file diff --git a/app/src/main/resources/db/dev-data/V2.2__insert_recipes.sql b/app/src/main/resources/db/dev-data/V2.2__insert_recipes.sql index 743b399..6f8f5bc 100644 --- a/app/src/main/resources/db/dev-data/V2.2__insert_recipes.sql +++ b/app/src/main/resources/db/dev-data/V2.2__insert_recipes.sql @@ -1,15 +1,15 @@ -- Insert demo recipes -INSERT INTO recipe (title, description, num_servings, preparation_time, author_id) -SELECT title, description, num_servings, preparation_time, ua.id -FROM (VALUES ('Spaghetti Bolognese', 'Classic Italian pasta dish.', 4, 'MIN_0_15', 'Robert'), - ('Chicken Curry', 'Spicy Indian-style curry with tender chicken.', 4, 'MIN_0_15', 'Breus'), - ('Caesar Salad', 'Classic romaine lettuce salad with creamy dressing.', 4, 'MIN_15_30', 'ChefMaster'), - ('Chocolate Chip Cookies', 'Soft and chewy homemade cookies.', 4, 'MIN_15_30', 'FoodLover'), - ('Mushroom Risotto', 'Creamy Italian rice dish with mushrooms.', 4, 'MIN_30_45', 'Breus'), - ('Fish Tacos', 'Mexican-style fish tacos with fresh salsa.', 4, 'MIN_30_45', 'ChefMaster'), - ('Greek Salad', 'Traditional Mediterranean salad with feta cheese.', 4, 'HOUR_PLUS', 'FoodLover'), - ('Beef Stir Fry', 'Quick and easy Asian-style beef dish.', 4, 'HOUR_PLUS', 'Robert'), - ('Banana Bread', 'Moist and delicious homemade bread.', 4, 'MIN_15_30', NULL), - ('Vegetable Soup', 'Healthy and warming soup with mixed vegetables.', 4, 'MIN_15_30', - NULL)) AS recipes(title, description, num_servings, preparation_time, username) +INSERT INTO recipe (title, description, image_name, num_servings, preparation_time, author_id) +SELECT title, description, image_name, num_servings, preparation_time, ua.id +FROM (VALUES ('Spaghetti Bolognese', 'Classic Italian pasta dish.', 'spaghetti.jpg', 4, 'MIN_0_15', 'Robert'), + ('Chicken Curry', 'Spicy Indian-style curry with tender chicken.', null, 4, 'MIN_0_15', 'Breus'), + ('Caesar Salad', 'Classic romaine lettuce salad with creamy dressing.', null, 4, 'MIN_15_30', 'ChefMaster'), + ('Chocolate Chip Cookies', 'Soft and chewy homemade cookies.', null, 4, 'MIN_15_30', 'FoodLover'), + ('Mushroom Risotto', 'Creamy Italian rice dish with mushrooms.', null, 4, 'MIN_30_45', 'Breus'), + ('Fish Tacos', 'Mexican-style fish tacos with fresh salsa.', null, 4, 'MIN_30_45', 'ChefMaster'), + ('Greek Salad', 'Traditional Mediterranean salad with feta cheese.', null, 4, 'HOUR_PLUS', 'FoodLover'), + ('Beef Stir Fry', 'Quick and easy Asian-style beef dish.', null, 4, 'HOUR_PLUS', 'Robert'), + ('Banana Bread', 'Moist and delicious homemade bread.', null, 4, 'MIN_15_30', NULL), + ('Vegetable Soup', 'Healthy and warming soup with mixed vegetables.', null, 4, 'MIN_15_30', + NULL)) AS recipes(title, description, image_name, num_servings, preparation_time, username) LEFT JOIN user_account ua ON ua.username = recipes.username; diff --git a/app/src/main/resources/db/migration/V1.2__create_recipe_table.sql b/app/src/main/resources/db/migration/V1.2__create_recipe_table.sql index f5110c7..989886b 100644 --- a/app/src/main/resources/db/migration/V1.2__create_recipe_table.sql +++ b/app/src/main/resources/db/migration/V1.2__create_recipe_table.sql @@ -3,6 +3,7 @@ CREATE TABLE recipe id BIGSERIAL PRIMARY KEY, title VARCHAR(255) NOT NULL, description TEXT, + image_name VARCHAR(255), num_servings INTEGER NOT NULL, preparation_time VARCHAR CHECK (preparation_time IN ('MIN_0_15', 'MIN_15_30', 'MIN_30_45', 'MIN_45_60', 'HOUR_PLUS')), diff --git a/app/src/test/java/dev/blaauwendraad/recipe_book/service/UserServiceTest.java b/app/src/test/java/dev/blaauwendraad/recipe_book/service/UserServiceTest.java index a5d3cef..b9ea7aa 100644 --- a/app/src/test/java/dev/blaauwendraad/recipe_book/service/UserServiceTest.java +++ b/app/src/test/java/dev/blaauwendraad/recipe_book/service/UserServiceTest.java @@ -2,10 +2,10 @@ import dev.blaauwendraad.recipe_book.data.model.UserAccountEntity; import dev.blaauwendraad.recipe_book.repository.UserRepository; +import dev.blaauwendraad.recipe_book.service.exception.EntityNotFoundException; import dev.blaauwendraad.recipe_book.service.exception.UserAuthenticationException; import dev.blaauwendraad.recipe_book.service.model.UserAccount; import io.quarkus.elytron.security.common.BcryptUtil; -import jakarta.ws.rs.NotFoundException; import java.util.Set; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeAll; @@ -34,7 +34,7 @@ void setUp() { } @Test - void getUserTest() { + void getUserTest() throws EntityNotFoundException { // Given UserAccountEntity userAccountEntity = createUserAccountEntity(1L, "testuser@example.com"); Mockito.when(userRepository.findById(1L)).thenReturn(userAccountEntity); @@ -53,8 +53,8 @@ void getUserTest() { @Test void getUserTest_NotFound() { Assertions.assertThatThrownBy(() -> userService.getUser(2L)) - .isInstanceOf(NotFoundException.class) - .hasMessageContaining("User with given userId does not exist"); + .hasMessageContaining("Entity not found.") + .hasFieldOrPropertyWithValue("detailMessage", "User with given userId does not exist"); } @Test @@ -74,8 +74,9 @@ void updateEmail() throws Exception { @Test void updateEmail_NotFound() { Assertions.assertThatThrownBy(() -> userService.updateEmail(10L, "newemail@example.com", TEST_PASSWORD)) - .isInstanceOf(NotFoundException.class) - .hasMessageContaining("User with given userId does not exist"); + .isInstanceOf(EntityNotFoundException.class) + .hasMessageContaining("Entity not found.") + .hasFieldOrPropertyWithValue("detailMessage", "User with given userId does not exist"); } @Test @@ -126,8 +127,9 @@ void updatePassword() throws Exception { @Test void updatePassword_NotFound() { Assertions.assertThatThrownBy(() -> userService.updatePassword(10L, TEST_PASSWORD, "newpassword123")) - .isInstanceOf(NotFoundException.class) - .hasMessageContaining("User with given userId does not exist"); + .isInstanceOf(EntityNotFoundException.class) + .hasMessageContaining("Entity not found.") + .hasFieldOrPropertyWithValue("detailMessage", "User with given userId does not exist"); } @Test diff --git a/dev_local.py b/dev_local.py index 79ae46a..8f899c5 100755 --- a/dev_local.py +++ b/dev_local.py @@ -57,6 +57,33 @@ def start_docker_compose(): sys.exit(1) +def setup_garage(): + print("Starting Garage...") + try: + bucket_name = "images" + zone_name = "local" + garage_command = ["docker", "exec", "-it", "snapchef-garage-1", "/garage"] + output = subprocess.run(garage_command + ["status"], capture_output=True, text=True, check=True) + if zone_name in output.stdout: + print("Garage already set up.") + return + node_id = output.stdout.splitlines()[4].split(" ")[0] + subprocess.run(garage_command + ["layout", "assign", node_id, "-z", zone_name, "-c", "1G"], check=True) + subprocess.run(garage_command + ["layout", "apply", "--version", "1"], check=True) + subprocess.run(garage_command + ["bucket", "create", bucket_name], check=True) + output = subprocess.run(garage_command + ["key", "create", bucket_name + "-key"], capture_output=True, text=True, check=True) + key_id = output.stdout.splitlines()[3].split(":")[1].strip() + key_secret = output.stdout.splitlines()[5].split(":")[1].strip() + with open("app/.env", "w") as file: + file.write(f"S3_ACCESS_KEY_ID={key_id}\n") + file.write(f"S3_ACCESS_KEY_SECRET={key_secret}\n") + subprocess.run(garage_command + ["bucket", "allow", "--read", "--write", "--owner", bucket_name, "--key", bucket_name + "-key"], check=True) + subprocess.run(garage_command + ["bucket", "website", "--allow", bucket_name], check=True) + except subprocess.CalledProcessError as exception: + print(f"Failed to setup Garage: {exception}") + sys.exit(1) + + def start_quarkus_dev(): print("Starting Quarkus Dev...") proc = subprocess.Popen( @@ -89,6 +116,7 @@ def main(): signal.signal(signal.SIGINT, cleanup) signal.signal(signal.SIGTERM, cleanup) start_docker_compose() + setup_garage() start_quarkus_dev() diff --git a/docker-compose-local-dev.yml b/docker-compose-local-dev.yml index ddf3f15..77e1f78 100644 --- a/docker-compose-local-dev.yml +++ b/docker-compose-local-dev.yml @@ -11,6 +11,17 @@ services: volumes: - pg_data:/var/lib/postgresql/data + garage: + image: dxflrs/garage:v2.1.0 + ports: + - "3900:3900" # S3 API + - "3901:3901" # RPC + - "3902:3902" # Web (static website) + volumes: + - ./garage.toml:/etc/garage.toml + - garage_meta:/var/lib/garage/meta + - garage_data:/var/lib/garage/data + quarkus: build: context: ./app @@ -21,5 +32,8 @@ services: PRIV_KEY_LOCATION: classpath:local-dev-keys/privateKey.pem ports: - "8081:8081" + volumes: - pg_data: \ No newline at end of file + pg_data: + garage_meta: + garage_data: \ No newline at end of file diff --git a/garage.toml b/garage.toml new file mode 100644 index 0000000..90ac1ca --- /dev/null +++ b/garage.toml @@ -0,0 +1,20 @@ +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +db_engine = "lmdb" +metadata_auto_snapshot_interval = "6h" + +replication_factor = 1 +compression_level = 1 + +rpc_bind_addr = "[::]:3901" +rpc_public_addr = "host.docker.internal:3901" # IP of host +rpc_secret = "46f752efc771a91a5314ec709f2bf772942393ad84e4487ef9b863cf16a3a054" + +[s3_api] # API of Garage +s3_region = "garage" +api_bind_addr = "[::]:3900" +root_domain = ".s3.garage" + +[s3_web] # API of static website +bind_addr = "[::]:3902" +root_domain = ".web.garage.localhost" \ No newline at end of file diff --git a/ui/src/api/httpClient.ts b/ui/src/api/httpClient.ts index 90c0b3d..d664f1b 100644 --- a/ui/src/api/httpClient.ts +++ b/ui/src/api/httpClient.ts @@ -3,7 +3,8 @@ import { useAuth } from "../auth/useAuth"; import { refreshAccessToken } from "./userAuthenticationApi"; const BACKEND_HOST_URL = import.meta.env.VITE_BACKEND_HOST_URL || "http://localhost:8081"; -const API_BASE_URL = `${BACKEND_HOST_URL}/api/`; +export const API_BASE_URL = `${BACKEND_HOST_URL}/api`; +export const OBJECT_STORAGE_BASE_URL = "http://images.web.garage.localhost:3902"; // Types for HTTP client export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; @@ -55,13 +56,6 @@ export class HttpError extends Error { } } -// Default request configuration -const defaultConfig: RequestConfig = { - headers: { - "Content-Type": "application/json", - }, -}; - // Helper function to build URL with query parameters const buildUrl = (path: string, params?: Record): string => { const queryString = params ? `?${new URLSearchParams(params).toString()}` : ""; @@ -72,8 +66,12 @@ const buildUrl = (path: string, params?: Record): string => { // Implementation of the HTTP client class FetchHttpClient implements HttpClient { - async request(method: HttpMethod, path: string, data?: unknown, config?: RequestConfig): Promise { - const mergedConfig = {...defaultConfig, ...config}; + async request(method: HttpMethod, path: string, data?: unknown, config?: RequestConfig, formdata: boolean = false): Promise { + var defaultHeaders = {}; + if(!formdata) { + defaultHeaders = {"Content-Type": "application/json"}; + } + const mergedConfig = { headers: defaultHeaders, ...config }; let {headers, params, signal, auth} = mergedConfig; if (auth === "accessToken") { @@ -92,12 +90,14 @@ class FetchHttpClient implements HttpClient { const requestOptions: RequestInit = { method, - headers: {...defaultConfig.headers, ...headers}, + headers: {...defaultHeaders, ...headers}, signal, }; - if (data && ["POST", "PUT", "PATCH"].includes(method)) { + if (data && !formdata && ["POST", "PUT", "PATCH"].includes(method)) { requestOptions.body = JSON.stringify(data); + } else if (data && formdata && ["POST", "PUT", "PATCH"].includes(method)) { + requestOptions.body = data as FormData; } try { @@ -166,3 +166,4 @@ export const post = httpClient.post.bind(httpClient); export const put = httpClient.put.bind(httpClient); export const del = httpClient.delete.bind(httpClient); export const patch = httpClient.patch.bind(httpClient); +export const request = httpClient.request.bind(httpClient); \ No newline at end of file diff --git a/ui/src/api/recipeApi.ts b/ui/src/api/recipeApi.ts index 0807120..2cd3706 100644 --- a/ui/src/api/recipeApi.ts +++ b/ui/src/api/recipeApi.ts @@ -1,22 +1,36 @@ +import type { PreparationTime } from "../models/domain/PreparationTime.ts"; import type Recipe from "../models/domain/Recipe.ts"; import type CreateRecipeResponse from "../models/dto/CreateRecipeResponse.ts"; -import type RecipeCreateDto from "../models/dto/RecipeCreateDto.ts"; -import type RecipeResponse from "../models/dto/RecipeResponse.ts"; -import { get, post, put, del } from "./httpClient.ts"; +import type RecipeDto from "../models/dto/RecipeDto.ts"; +import { get, request, del } from "./httpClient.ts"; export const getRecipeById = async (id: number): Promise => { try { - const data = await get(`recipes/${id}`); - return data.recipe; + const data = await get(`recipes/${id}`); + const recipe: Recipe = { + title: data.title.trim(), + description: data.description.trim(), + imageName: data.imageName, + numServings: data.numServings, + preparationTime: data.preparationTime as PreparationTime, + author: { + userId: data.author.userId, + username: data.author.username, + }, + id: data.id, + ingredients: data.ingredients, + preparationSteps: data.preparationSteps, + }; + return recipe; } catch (error) { console.error("Error fetching recipe:", error); throw error; } }; -export const createRecipe = async (recipe: RecipeCreateDto): Promise => { +export const createRecipe = async (recipe: FormData): Promise => { try { - const data = await post("recipes", recipe, { auth: "accessToken" }); + const data = await request("POST", "recipes", recipe, { auth: "accessToken" }, true); return data.recipeId; } catch (error) { console.error("Error creating recipe:", error); @@ -24,9 +38,9 @@ export const createRecipe = async (recipe: RecipeCreateDto): Promise => } }; -export const updateRecipe = async (id: number, recipe: RecipeCreateDto): Promise => { +export const updateRecipe = async (id: number, recipe: FormData): Promise => { try { - await put(`recipes/${id}`, recipe, { auth: "accessToken" }); + await request("PUT", `recipes/${id}`, recipe, { auth: "accessToken" }, true); } catch (error) { console.error("Error updating recipe:", error); throw error; diff --git a/ui/src/components/CreateRecipe.vue b/ui/src/components/CreateRecipe.vue index 024adcc..f8b8ad2 100644 --- a/ui/src/components/CreateRecipe.vue +++ b/ui/src/components/CreateRecipe.vue @@ -4,10 +4,11 @@ import {useRoute, useRouter} from "vue-router"; import {createRecipe, getRecipeById, updateRecipe} from "../api/recipeApi.ts"; import type Ingredient from "../models/domain/Ingredient.ts"; import type PreparationStep from "../models/domain/PreparationStep.ts"; -import type RecipeCreateDto from "../models/dto/RecipeCreateDto.ts"; import type Recipe from "../models/domain/Recipe.ts"; import {useAuth} from "../auth/useAuth.ts"; import {PreparationTime} from "../models/domain/PreparationTime.ts"; +import type RecipeCreateDto from "../models/dto/RecipeCreateDto.ts"; +import {API_BASE_URL} from "../api/httpClient.ts"; import { VueDraggableNext } from 'vue-draggable-next'; const router = useRouter(); @@ -23,6 +24,8 @@ const numServings = ref(4); const preparationTime = ref(PreparationTime.MIN_15_30); const ingredients = ref([{description: ""}]); const preparationSteps = ref([{description: ""}]); +const recipeImage = ref(null); +const imagePreviewUrl = ref(null); // Form state const isSubmitting = ref(false); @@ -186,6 +189,20 @@ onMounted(async () => { ingredients.value = [...recipe.ingredients, {description: ""}]; preparationSteps.value = [...recipe.preparationSteps, {description: ""}]; + if(recipe.imageName) { + try { + const response = await fetch(`${API_BASE_URL}/recipes/${recipe.id}/image`); + if (response.ok) { + const blob = await response.blob(); + const file = new File([blob], "recipe-image.jpg", { type: blob.type }); + recipeImage.value = file; + imagePreviewUrl.value = URL.createObjectURL(blob); + } + } catch (err) { + console.error("Failed to load recipe image:", err); + } + } + // Auto-resize all textareas after loading the data nextTick(() => { autoResizeAllTextareas(); @@ -231,6 +248,7 @@ const submitForm = async () => { throw new Error("Number of servings must be between 1 and 100"); } + const newRecipeFormData = new FormData(); const newRecipe: RecipeCreateDto = { title: title.value.trim(), description: description.value.trim(), @@ -239,6 +257,10 @@ const submitForm = async () => { ingredients: validIngredients, preparationSteps: validSteps, }; + newRecipeFormData.append("newRecipe", new Blob([JSON.stringify(newRecipe)], { type: 'application/json' })); + if (recipeImage.value) { + newRecipeFormData.append("recipeImage", recipeImage.value); + } if (!isLoggedIn()) { throw new Error("You must be logged in to create a recipe."); @@ -246,10 +268,10 @@ const submitForm = async () => { let goToRecipeId: number; if (isEditMode && recipeId) { // Update existing recipe - await updateRecipe(Number(recipeId), newRecipe); + await updateRecipe(Number(recipeId), newRecipeFormData); goToRecipeId = Number(recipeId); } else { - goToRecipeId = await createRecipe(newRecipe); + goToRecipeId = await createRecipe(newRecipeFormData); } router.push(`/recipe/${goToRecipeId}`); } catch (err) { @@ -271,6 +293,106 @@ const cancelEdit = () => { router.push("/"); } }; + +// Compress image using Canvas API +const compressImage = async (file: File, maxWidth = 1200, maxHeight = 1200, quality = 0.8): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + + reader.onload = (e) => { + const img = new Image(); + img.src = e.target?.result as string; + + img.onload = () => { + // Calculate new dimensions while maintaining aspect ratio + let width = img.width; + let height = img.height; + + if (width > height) { + if (width > maxWidth) { + height = Math.round((height * maxWidth) / width); + width = maxWidth; + } + } else { + if (height > maxHeight) { + width = Math.round((width * maxHeight) / height); + height = maxHeight; + } + } + + // Create canvas and draw image + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Failed to get canvas context')); + return; + } + + ctx.drawImage(img, 0, 0, width, height); + + // Convert canvas to blob + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Failed to compress image')); + return; + } + + // Create new file from blob + const compressedFile = new File([blob], file.name, { + type: 'image/jpeg', + lastModified: Date.now(), + }); + + resolve(compressedFile); + }, + 'image/jpeg', + quality + ); + }; + + img.onerror = () => { + reject(new Error('Failed to load image')); + }; + }; + + reader.onerror = () => { + reject(new Error('Failed to read file')); + }; + }); +}; + +const handleImageUpload = async (event: Event) => { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + + if (file) { + // Check file type + if (!file.type.startsWith('image/')) { + error.value = "File must be an image"; + return; + } + + try { + const compressedFile = await compressImage(file); + recipeImage.value = compressedFile; + imagePreviewUrl.value = URL.createObjectURL(recipeImage.value); + + } catch (err) { + console.error('Image compression error:', err); + error.value = "Failed to process image. Please try another image."; + } + } +}; + +const removeImage = () => { + recipeImage.value = null; + imagePreviewUrl.value = null; +};