diff --git a/commons/src/main/java/commons/ws/Topics.java b/commons/src/main/java/commons/ws/Topics.java new file mode 100644 index 0000000..9eb8d9f --- /dev/null +++ b/commons/src/main/java/commons/ws/Topics.java @@ -0,0 +1,5 @@ +package commons.ws; + +public class Topics { + public static final String RECIPES = "/subscribe/recipe"; +} diff --git a/commons/src/main/java/commons/ws/messages/CreateRecipeMessage.java b/commons/src/main/java/commons/ws/messages/CreateRecipeMessage.java new file mode 100644 index 0000000..b036c1f --- /dev/null +++ b/commons/src/main/java/commons/ws/messages/CreateRecipeMessage.java @@ -0,0 +1,30 @@ +package commons.ws.messages; + +import commons.Recipe; + +/** + * Message sent when a new recipe is created. + * + * @see commons.ws.messages.Message.Type#RECIPE_CREATE + */ +public class CreateRecipeMessage implements Message { + private Recipe recipe; + + public CreateRecipeMessage(Recipe recipe) { + this.recipe = recipe; + } + + @Override + public Type getType() { + return Type.RECIPE_CREATE; + } + + /** + * Get the created recipe. + * + * @return The created recipe. + */ + public Recipe getRecipe() { + return recipe; + } +} diff --git a/commons/src/main/java/commons/ws/messages/DeleteRecipeMessage.java b/commons/src/main/java/commons/ws/messages/DeleteRecipeMessage.java new file mode 100644 index 0000000..1802525 --- /dev/null +++ b/commons/src/main/java/commons/ws/messages/DeleteRecipeMessage.java @@ -0,0 +1,28 @@ +package commons.ws.messages; + +/** + * Message sent when a recipe is deleted. + * + * @see commons.ws.messages.Message.Type#RECIPE_DELETE + */ +public class DeleteRecipeMessage implements Message { + private Long recipeId; + + public DeleteRecipeMessage(Long recipeId) { + this.recipeId = recipeId; + } + + @Override + public Type getType() { + return Type.RECIPE_DELETE; + } + + /** + * Get the ID of the deleted recipe. + * + * @return The ID of the deleted recipe. + */ + public Long getRecipeId() { + return recipeId; + } +} diff --git a/commons/src/main/java/commons/ws/messages/Message.java b/commons/src/main/java/commons/ws/messages/Message.java new file mode 100644 index 0000000..681474f --- /dev/null +++ b/commons/src/main/java/commons/ws/messages/Message.java @@ -0,0 +1,44 @@ +package commons.ws.messages; + +public interface Message { + public enum Type { + /** + * Message sent when a new recipe is created. + * + * @see commons.ws.messages.CreateRecipeMessage + */ + RECIPE_CREATE, + + /** + * Message sent when an existing recipe is updated. + * + * @see commons.ws.messages.UpdateRecipeMessage + */ + RECIPE_UPDATE, + + /** + * Message sent when a recipe is deleted. + * + * @see commons.ws.messages.DeleteRecipeMessage + */ + RECIPE_DELETE + } + + /** + * Get the type of the message. + * This can be used to match the message to the appropriate handler. + * + *

Example

+ *
+     * Message msg = ...;
+     * switch (msg.getType()) {
+     *     case RECIPE_CREATE -> handleCreate((CreateRecipeMessage) msg);
+     *     case RECIPE_UPDATE -> handleUpdate((UpdateRecipeMessage) msg);
+     *     default -> { /* handle other cases *\/ }
+     * }
+     * 
+ * + * @return The type of the message. + */ + public Type getType(); +} diff --git a/commons/src/main/java/commons/ws/messages/UpdateRecipeMessage.java b/commons/src/main/java/commons/ws/messages/UpdateRecipeMessage.java new file mode 100644 index 0000000..c623b75 --- /dev/null +++ b/commons/src/main/java/commons/ws/messages/UpdateRecipeMessage.java @@ -0,0 +1,30 @@ +package commons.ws.messages; + +import commons.Recipe; + +/** + * Message sent when an existing recipe is updated. + * + * @see commons.ws.messages.Message.Type#RECIPE_UPDATE + */ +public class UpdateRecipeMessage implements Message { + private Recipe recipe; + + public UpdateRecipeMessage(Recipe recipe) { + this.recipe = recipe; + } + + @Override + public Type getType() { + return Type.RECIPE_UPDATE; + } + + /** + * Get the updated recipe. + * + * @return The updated recipe. + */ + public Recipe getRecipe() { + return recipe; + } +} diff --git a/server/pom.xml b/server/pom.xml index e4e9e6a..bd9a370 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -26,6 +26,10 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-websocket + com.h2database h2 @@ -43,6 +47,7 @@ spring-boot-starter-test test + diff --git a/server/src/main/java/server/WebSocketConfig.java b/server/src/main/java/server/WebSocketConfig.java new file mode 100644 index 0000000..96d40d5 --- /dev/null +++ b/server/src/main/java/server/WebSocketConfig.java @@ -0,0 +1,33 @@ +package server; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + + // SUBSCRIBE /subscribe/{mapping}, see UpdateMessagingController definition + // adds a broker endpoint subscribers listen to + registry.enableSimpleBroker("/subscribe"); + + // SEND /send/updates/{mapping}, see UpdateMessagingController definition + // the client pushes a new message to this address + registry.setApplicationDestinationPrefixes("/send"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // a client can now push data to /send/updates/{mapping}, see UpdateMessagingController + // allow access from any origin: one server, many clients + registry.addEndpoint("/updates").setAllowedOrigins("*"); + // optional, recommended to increase browser & networking support + registry.addEndpoint("/updates").setAllowedOrigins("*").withSockJS(); + + } +} diff --git a/server/src/main/java/server/api/RecipeController.java b/server/src/main/java/server/api/RecipeController.java index a1d79fe..989f9f0 100644 --- a/server/src/main/java/server/api/RecipeController.java +++ b/server/src/main/java/server/api/RecipeController.java @@ -2,10 +2,15 @@ package server.api; import commons.Recipe; +import commons.ws.Topics; +import commons.ws.messages.CreateRecipeMessage; +import commons.ws.messages.DeleteRecipeMessage; +import commons.ws.messages.UpdateRecipeMessage; import org.springframework.data.domain.Example; import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -25,9 +30,11 @@ import java.util.Optional; @RequestMapping("/api") public class RecipeController { private final RecipeRepository recipeRepository; // JPA repository used in this controller + private final SimpMessagingTemplate messagingTemplate; - public RecipeController(RecipeRepository recipeRepository) { + public RecipeController(RecipeRepository recipeRepository, SimpMessagingTemplate messagingTemplate) { this.recipeRepository = recipeRepository; + this.messagingTemplate = messagingTemplate; } /** @@ -61,6 +68,7 @@ public class RecipeController { PageRequest.of(0, limit.get()) ).toList()); } + return ResponseEntity.ok(recipeRepository.findAll()); } @@ -76,9 +84,10 @@ public class RecipeController { return ResponseEntity.badRequest().build(); } - // TODO: Send WS update to all subscribers with the updated recipe + Recipe saved = recipeRepository.save(recipe); + messagingTemplate.convertAndSend(Topics.RECIPES, new UpdateRecipeMessage(saved)); - return ResponseEntity.ok(recipeRepository.save(recipe)); + return ResponseEntity.ok(saved); } /** @@ -104,9 +113,10 @@ public class RecipeController { return ResponseEntity.badRequest().build(); } - // TODO: Send WS update to all subscribers with the new recipe + Recipe saved = recipeRepository.save(recipe); + messagingTemplate.convertAndSend(Topics.RECIPES, new CreateRecipeMessage(saved)); - return ResponseEntity.ok(recipeRepository.save(recipe)); + return ResponseEntity.ok(saved); } /** @@ -124,7 +134,8 @@ public class RecipeController { } recipeRepository.deleteById(id); - // TODO: Send WS update to propagate deletion + messagingTemplate.convertAndSend(Topics.RECIPES, new DeleteRecipeMessage(id)); + return ResponseEntity.ok(true); } } diff --git a/server/src/main/java/server/api/UpdateMessagingController.java b/server/src/main/java/server/api/UpdateMessagingController.java new file mode 100644 index 0000000..a78d0fb --- /dev/null +++ b/server/src/main/java/server/api/UpdateMessagingController.java @@ -0,0 +1,29 @@ +package server.api; + +import commons.Recipe; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; + +@Controller +public class UpdateMessagingController { + private final RecipeController recipeController; + @Autowired + public UpdateMessagingController( + RecipeController recipeController) { + this.recipeController = recipeController; + } + + /** + * Mapping for STOMP: SEND /updates/recipe + * @param recipe The request body as a new {@link Recipe} object to update the original into + * @return The updated {@link Recipe} object wrapped in a {@link org.springframework.http.ResponseEntity}. + */ + @MessageMapping("/updates/recipe") + @SendTo("/subscribe/recipe") + public ResponseEntity broadcastRecipeUpdate(Recipe recipe) { + return recipeController.updateRecipe(recipe.getId(), recipe); + } +} diff --git a/server/src/test/java/server/api/RecipeControllerTest.java b/server/src/test/java/server/api/RecipeControllerTest.java index f1a766e..9b27d2c 100644 --- a/server/src/test/java/server/api/RecipeControllerTest.java +++ b/server/src/test/java/server/api/RecipeControllerTest.java @@ -8,8 +8,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.test.context.ActiveProfiles; +import server.WebSocketConfig; import server.database.RecipeRepository; import java.util.ArrayList; @@ -29,7 +32,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; // resources/application-mock-data-test.properties // This config uses an in-memory database @ActiveProfiles("mock-data-test") + +// This is required to enable WebSocket messaging in tests +// +// Without this line, Spring screams about missing SimpMessagingTemplate bean +@Import(WebSocketConfig.class) public class RecipeControllerTest { + + private final SimpMessagingTemplate template; private RecipeController controller; private List recipes; private final RecipeRepository recipeRepository; @@ -38,8 +48,9 @@ public class RecipeControllerTest { // Injects a test repository into the test class @Autowired - public RecipeControllerTest(RecipeRepository recipeRepository) { + public RecipeControllerTest(RecipeRepository recipeRepository, SimpMessagingTemplate template) { this.recipeRepository = recipeRepository; + this.template = template; } @BeforeEach @@ -48,7 +59,7 @@ public class RecipeControllerTest { .range(0, NUM_RECIPES) .mapToObj(x -> new Recipe(null, "Recipe " + x, List.of(), List.of())) .toList(); - controller = new RecipeController(recipeRepository); + controller = new RecipeController(recipeRepository, template); Set tags = info.getTags(); List ids = new ArrayList<>(); @@ -67,6 +78,7 @@ public class RecipeControllerTest { // If no tags specified, the repository is initialized as empty. } + @Test public void createOneRecipe() { controller.createRecipe(recipes.getFirst()); @@ -87,6 +99,7 @@ public class RecipeControllerTest { // The number of recipes returned is the same as the entire input list assertEquals(recipes.size(), controller.getRecipes(Optional.empty()).getBody().size()); } + @Test @Tag("test-from-init-data") public void getSomeRecipes() { @@ -94,6 +107,7 @@ public class RecipeControllerTest { // The number of recipes returned is the same as the entire input list assertEquals(LIMIT, controller.getRecipes(Optional.of(LIMIT)).getBody().size()); } + @Test @Tag("test-from-init-data") @Tag("need-ids") @@ -104,6 +118,7 @@ public class RecipeControllerTest { recipes.get(CHECK_INDEX), controller.getRecipe(recipeIds.get(CHECK_INDEX)).getBody()); } + @Test public void findOneRecipeNotExists() { final int CHECK_INDEX = 3; @@ -112,6 +127,7 @@ public class RecipeControllerTest { HttpStatus.NOT_FOUND, controller.getRecipe((long) CHECK_INDEX).getStatusCode()); } + @Test @Tag("test-from-init-data") @Tag("need-ids") @@ -121,15 +137,18 @@ public class RecipeControllerTest { // The object has been successfully deleted assertEquals(HttpStatus.OK, controller.deleteRecipe(recipeIds.get(DELETE_INDEX)).getStatusCode()); } + @Test @Tag("test-from-init-data") @Tag("need-ids") public void deleteOneRecipeCountGood() { final int DELETE_INDEX = 5; controller.deleteRecipe(recipeIds.get(DELETE_INDEX)); + // The count of items decreased by 1 after the 5th item has been removed. assertEquals(recipeIds.size() - 1, recipeRepository.count()); } + @Test public void deleteOneRecipeFail() { final Long DELETE_INDEX = 5L;