Merge branch 'feature/backend-websockets' into 'main'
Backend websockets See merge request cse1105/2025-2026/teams/csep-team-76!16
This commit is contained in:
commit
433f289c55
10 changed files with 242 additions and 8 deletions
5
commons/src/main/java/commons/ws/Topics.java
Normal file
5
commons/src/main/java/commons/ws/Topics.java
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
package commons.ws;
|
||||||
|
|
||||||
|
public class Topics {
|
||||||
|
public static final String RECIPES = "/subscribe/recipe";
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
commons/src/main/java/commons/ws/messages/Message.java
Normal file
44
commons/src/main/java/commons/ws/messages/Message.java
Normal file
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <h3>Example</h3>
|
||||||
|
* <pre>
|
||||||
|
* Message msg = ...;
|
||||||
|
* switch (msg.getType()) {
|
||||||
|
* case RECIPE_CREATE -> handleCreate((CreateRecipeMessage) msg);
|
||||||
|
* case RECIPE_UPDATE -> handleUpdate((UpdateRecipeMessage) msg);
|
||||||
|
* default -> { /* handle other cases *\/ }
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @return The type of the message.
|
||||||
|
*/
|
||||||
|
public Type getType();
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,10 @@
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.h2database</groupId>
|
<groupId>com.h2database</groupId>
|
||||||
<artifactId>h2</artifactId>
|
<artifactId>h2</artifactId>
|
||||||
|
|
@ -43,6 +47,7 @@
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|
|
||||||
33
server/src/main/java/server/WebSocketConfig.java
Normal file
33
server/src/main/java/server/WebSocketConfig.java
Normal file
|
|
@ -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();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,15 @@ package server.api;
|
||||||
|
|
||||||
import commons.Recipe;
|
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.Example;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
|
@ -25,9 +30,11 @@ import java.util.Optional;
|
||||||
@RequestMapping("/api")
|
@RequestMapping("/api")
|
||||||
public class RecipeController {
|
public class RecipeController {
|
||||||
private final RecipeRepository recipeRepository; // JPA repository used in this controller
|
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.recipeRepository = recipeRepository;
|
||||||
|
this.messagingTemplate = messagingTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,6 +68,7 @@ public class RecipeController {
|
||||||
PageRequest.of(0, limit.get())
|
PageRequest.of(0, limit.get())
|
||||||
).toList());
|
).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(recipeRepository.findAll());
|
return ResponseEntity.ok(recipeRepository.findAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,9 +84,10 @@ public class RecipeController {
|
||||||
return ResponseEntity.badRequest().build();
|
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();
|
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);
|
recipeRepository.deleteById(id);
|
||||||
|
|
||||||
// TODO: Send WS update to propagate deletion
|
messagingTemplate.convertAndSend(Topics.RECIPES, new DeleteRecipeMessage(id));
|
||||||
|
|
||||||
return ResponseEntity.ok(true);
|
return ResponseEntity.ok(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: <code>SEND /updates/recipe</code>
|
||||||
|
* @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<Recipe> broadcastRecipeUpdate(Recipe recipe) {
|
||||||
|
return recipeController.updateRecipe(recipe.getId(), recipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,11 @@ import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.TestInfo;
|
import org.junit.jupiter.api.TestInfo;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import server.WebSocketConfig;
|
||||||
import server.database.RecipeRepository;
|
import server.database.RecipeRepository;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
@ -29,7 +32,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
// resources/application-mock-data-test.properties
|
// resources/application-mock-data-test.properties
|
||||||
// This config uses an in-memory database
|
// This config uses an in-memory database
|
||||||
@ActiveProfiles("mock-data-test")
|
@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 {
|
public class RecipeControllerTest {
|
||||||
|
|
||||||
|
private final SimpMessagingTemplate template;
|
||||||
private RecipeController controller;
|
private RecipeController controller;
|
||||||
private List<Recipe> recipes;
|
private List<Recipe> recipes;
|
||||||
private final RecipeRepository recipeRepository;
|
private final RecipeRepository recipeRepository;
|
||||||
|
|
@ -38,8 +48,9 @@ public class RecipeControllerTest {
|
||||||
|
|
||||||
// Injects a test repository into the test class
|
// Injects a test repository into the test class
|
||||||
@Autowired
|
@Autowired
|
||||||
public RecipeControllerTest(RecipeRepository recipeRepository) {
|
public RecipeControllerTest(RecipeRepository recipeRepository, SimpMessagingTemplate template) {
|
||||||
this.recipeRepository = recipeRepository;
|
this.recipeRepository = recipeRepository;
|
||||||
|
this.template = template;
|
||||||
}
|
}
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
|
|
@ -48,7 +59,7 @@ public class RecipeControllerTest {
|
||||||
.range(0, NUM_RECIPES)
|
.range(0, NUM_RECIPES)
|
||||||
.mapToObj(x -> new Recipe(null, "Recipe " + x, List.of(), List.of()))
|
.mapToObj(x -> new Recipe(null, "Recipe " + x, List.of(), List.of()))
|
||||||
.toList();
|
.toList();
|
||||||
controller = new RecipeController(recipeRepository);
|
controller = new RecipeController(recipeRepository, template);
|
||||||
Set<String> tags = info.getTags();
|
Set<String> tags = info.getTags();
|
||||||
List<Long> ids = new ArrayList<>();
|
List<Long> ids = new ArrayList<>();
|
||||||
|
|
||||||
|
|
@ -67,6 +78,7 @@ public class RecipeControllerTest {
|
||||||
|
|
||||||
// If no tags specified, the repository is initialized as empty.
|
// If no tags specified, the repository is initialized as empty.
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void createOneRecipe() {
|
public void createOneRecipe() {
|
||||||
controller.createRecipe(recipes.getFirst());
|
controller.createRecipe(recipes.getFirst());
|
||||||
|
|
@ -87,6 +99,7 @@ public class RecipeControllerTest {
|
||||||
// The number of recipes returned is the same as the entire input list
|
// The number of recipes returned is the same as the entire input list
|
||||||
assertEquals(recipes.size(), controller.getRecipes(Optional.empty()).getBody().size());
|
assertEquals(recipes.size(), controller.getRecipes(Optional.empty()).getBody().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Tag("test-from-init-data")
|
@Tag("test-from-init-data")
|
||||||
public void getSomeRecipes() {
|
public void getSomeRecipes() {
|
||||||
|
|
@ -94,6 +107,7 @@ public class RecipeControllerTest {
|
||||||
// The number of recipes returned is the same as the entire input list
|
// The number of recipes returned is the same as the entire input list
|
||||||
assertEquals(LIMIT, controller.getRecipes(Optional.of(LIMIT)).getBody().size());
|
assertEquals(LIMIT, controller.getRecipes(Optional.of(LIMIT)).getBody().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Tag("test-from-init-data")
|
@Tag("test-from-init-data")
|
||||||
@Tag("need-ids")
|
@Tag("need-ids")
|
||||||
|
|
@ -104,6 +118,7 @@ public class RecipeControllerTest {
|
||||||
recipes.get(CHECK_INDEX),
|
recipes.get(CHECK_INDEX),
|
||||||
controller.getRecipe(recipeIds.get(CHECK_INDEX)).getBody());
|
controller.getRecipe(recipeIds.get(CHECK_INDEX)).getBody());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void findOneRecipeNotExists() {
|
public void findOneRecipeNotExists() {
|
||||||
final int CHECK_INDEX = 3;
|
final int CHECK_INDEX = 3;
|
||||||
|
|
@ -112,6 +127,7 @@ public class RecipeControllerTest {
|
||||||
HttpStatus.NOT_FOUND,
|
HttpStatus.NOT_FOUND,
|
||||||
controller.getRecipe((long) CHECK_INDEX).getStatusCode());
|
controller.getRecipe((long) CHECK_INDEX).getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Tag("test-from-init-data")
|
@Tag("test-from-init-data")
|
||||||
@Tag("need-ids")
|
@Tag("need-ids")
|
||||||
|
|
@ -121,15 +137,18 @@ public class RecipeControllerTest {
|
||||||
// The object has been successfully deleted
|
// The object has been successfully deleted
|
||||||
assertEquals(HttpStatus.OK, controller.deleteRecipe(recipeIds.get(DELETE_INDEX)).getStatusCode());
|
assertEquals(HttpStatus.OK, controller.deleteRecipe(recipeIds.get(DELETE_INDEX)).getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Tag("test-from-init-data")
|
@Tag("test-from-init-data")
|
||||||
@Tag("need-ids")
|
@Tag("need-ids")
|
||||||
public void deleteOneRecipeCountGood() {
|
public void deleteOneRecipeCountGood() {
|
||||||
final int DELETE_INDEX = 5;
|
final int DELETE_INDEX = 5;
|
||||||
controller.deleteRecipe(recipeIds.get(DELETE_INDEX));
|
controller.deleteRecipe(recipeIds.get(DELETE_INDEX));
|
||||||
|
|
||||||
// The count of items decreased by 1 after the 5th item has been removed.
|
// The count of items decreased by 1 after the 5th item has been removed.
|
||||||
assertEquals(recipeIds.size() - 1, recipeRepository.count());
|
assertEquals(recipeIds.size() - 1, recipeRepository.count());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void deleteOneRecipeFail() {
|
public void deleteOneRecipeFail() {
|
||||||
final Long DELETE_INDEX = 5L;
|
final Long DELETE_INDEX = 5L;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue