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:
Natalia Cholewa 2025-12-05 10:47:32 +01:00
commit 433f289c55
10 changed files with 242 additions and 8 deletions

View file

@ -0,0 +1,5 @@
package commons.ws;
public class Topics {
public static final String RECIPES = "/subscribe/recipe";
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View 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();
}

View file

@ -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;
}
}

View file

@ -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>

View 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();
}
}

View file

@ -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);
} }
} }

View file

@ -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);
}
}

View file

@ -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;