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;