Merge branch 'feature/ws-client' into 'main'

Feature/ws-client

See merge request cse1105/2025-2026/teams/csep-team-76!25
This commit is contained in:
Oskar Rasieński 2025-12-18 18:15:01 +01:00
commit 60efdc3a78
12 changed files with 244 additions and 2 deletions

View file

@ -34,21 +34,25 @@
<artifactId>jersey-client</artifactId> <artifactId>jersey-client</artifactId>
<version>${version.jersey}</version> <version>${version.jersey}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.glassfish.jersey.inject</groupId> <groupId>org.glassfish.jersey.inject</groupId>
<artifactId>jersey-hk2</artifactId> <artifactId>jersey-hk2</artifactId>
<version>${version.jersey}</version> <version>${version.jersey}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.glassfish.jersey.media</groupId> <groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-json-jackson</artifactId> <artifactId>jersey-media-json-jackson</artifactId>
<version>${version.jersey}</version> <version>${version.jersey}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>jakarta.activation</groupId> <groupId>jakarta.activation</groupId>
<artifactId>jakarta.activation-api</artifactId> <artifactId>jakarta.activation-api</artifactId>
<version>2.1.3</version> <version>2.1.3</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.google.inject</groupId> <groupId>com.google.inject</groupId>
<artifactId>guice</artifactId> <artifactId>guice</artifactId>
@ -61,11 +65,13 @@
<artifactId>javafx-fxml</artifactId> <artifactId>javafx-fxml</artifactId>
<version>${version.jfx}</version> <version>${version.jfx}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId> <artifactId>javafx-controls</artifactId>
<version>${version.jfx}</version> <version>${version.jfx}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId> <artifactId>javafx-web</artifactId>
@ -91,6 +97,27 @@
<version>${version.mockito}</version> <version>${version.mockito}</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- websockets -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
<version>6.2.12</version>
<scope>compile</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.glassfish.tyrus.bundles/tyrus-standalone-client -->
<dependency>
<groupId>org.glassfish.tyrus.bundles</groupId>
<artifactId>tyrus-standalone-client</artifactId>
<version>2.2.1</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
<version>6.2.12</version>
<scope>compile</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View file

@ -19,6 +19,8 @@ import client.scenes.FoodpalApplicationCtrl;
import client.scenes.recipe.IngredientListCtrl; import client.scenes.recipe.IngredientListCtrl;
import client.scenes.recipe.RecipeStepListCtrl; import client.scenes.recipe.RecipeStepListCtrl;
import client.utils.LocaleManager; import client.utils.LocaleManager;
import client.utils.ServerUtils;
import client.utils.WebSocketUtils;
import com.google.inject.Binder; import com.google.inject.Binder;
import com.google.inject.Module; import com.google.inject.Module;
import com.google.inject.Scopes; import com.google.inject.Scopes;
@ -33,6 +35,9 @@ public class MyModule implements Module {
binder.bind(FoodpalApplicationCtrl.class).in(Scopes.SINGLETON); binder.bind(FoodpalApplicationCtrl.class).in(Scopes.SINGLETON);
binder.bind(IngredientListCtrl.class).in(Scopes.SINGLETON); binder.bind(IngredientListCtrl.class).in(Scopes.SINGLETON);
binder.bind(RecipeStepListCtrl.class).in(Scopes.SINGLETON); binder.bind(RecipeStepListCtrl.class).in(Scopes.SINGLETON);
binder.bind(LocaleManager.class).in(Scopes.SINGLETON); binder.bind(LocaleManager.class).in(Scopes.SINGLETON);
binder.bind(ServerUtils.class).in(Scopes.SINGLETON);
binder.bind(WebSocketUtils.class).in(Scopes.SINGLETON);
} }
} }

View file

@ -13,10 +13,14 @@ import client.utils.DefaultRecipeFactory;
import client.utils.LocaleAware; import client.utils.LocaleAware;
import client.utils.LocaleManager; import client.utils.LocaleManager;
import client.utils.ServerUtils; import client.utils.ServerUtils;
import client.utils.WebSocketUtils;
import commons.Recipe; import commons.Recipe;
import commons.ws.Topics;
import commons.ws.messages.Message;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import javafx.application.Platform;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.Alert; import javafx.scene.control.Alert;
@ -32,6 +36,7 @@ import javafx.scene.text.Font;
public class FoodpalApplicationCtrl implements LocaleAware { public class FoodpalApplicationCtrl implements LocaleAware {
private final ServerUtils server; private final ServerUtils server;
private final WebSocketUtils webSocketUtils;
private final LocaleManager localeManager; private final LocaleManager localeManager;
private final IngredientListCtrl ingredientListCtrl; private final IngredientListCtrl ingredientListCtrl;
private final RecipeStepListCtrl stepListCtrl; private final RecipeStepListCtrl stepListCtrl;
@ -81,15 +86,42 @@ public class FoodpalApplicationCtrl implements LocaleAware {
@Inject @Inject
public FoodpalApplicationCtrl( public FoodpalApplicationCtrl(
ServerUtils server, ServerUtils server,
WebSocketUtils webSocketUtils,
LocaleManager localeManager, LocaleManager localeManager,
IngredientListCtrl ingredientListCtrl, IngredientListCtrl ingredientListCtrl,
RecipeStepListCtrl stepListCtrl RecipeStepListCtrl stepListCtrl
) { ) {
this.server = server; this.server = server;
this.webSocketUtils = webSocketUtils;
this.localeManager = localeManager; this.localeManager = localeManager;
this.ingredientListCtrl = ingredientListCtrl; this.ingredientListCtrl = ingredientListCtrl;
this.stepListCtrl = stepListCtrl; this.stepListCtrl = stepListCtrl;
initializeWebSocket();
} }
private void initializeWebSocket() {
webSocketUtils.connect(() -> {
webSocketUtils.subscribe(Topics.RECIPES, (Message _) -> {
Platform.runLater(() -> {
Recipe selectedRecipe = recipeList.getSelectionModel().getSelectedItem();
refresh(); // refresh the left list
if (selectedRecipe == null) {
return;
}
// select last selected recipe if it still exists, first otherwise (done by refresh())
Recipe recipeInList = recipeList.getItems().stream()
.filter(r -> r.getId().equals(selectedRecipe.getId()))
.findFirst()
.orElse(null);
showRecipeDetails(recipeInList);
}); // runLater as it's on another non-FX thread.
});
});
}
@Override @Override
public void initializeComponents() { public void initializeComponents() {
// TODO Reduce code duplication?? // TODO Reduce code duplication??
@ -146,6 +178,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
refresh(); refresh();
} }
private void showName(String name) { private void showName(String name) {
final int NAME_FONT_SIZE = 20; final int NAME_FONT_SIZE = 20;
editableTitleArea.getChildren().clear(); editableTitleArea.getChildren().clear();
@ -153,6 +186,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
nameLabel.setFont(new Font("System Bold", NAME_FONT_SIZE)); nameLabel.setFont(new Font("System Bold", NAME_FONT_SIZE));
editableTitleArea.getChildren().add(nameLabel); editableTitleArea.getChildren().add(nameLabel);
} }
@Override @Override
public void updateText() { public void updateText() {
addRecipeButton.setText(getLocaleString("menu.button.add.recipe")); addRecipeButton.setText(getLocaleString("menu.button.add.recipe"));
@ -206,11 +240,13 @@ public class FoodpalApplicationCtrl implements LocaleAware {
} }
detailsScreen.visibleProperty().set(!recipes.isEmpty()); detailsScreen.visibleProperty().set(!recipes.isEmpty());
} }
private void printError(String msg) { private void printError(String msg) {
Alert alert = new Alert(Alert.AlertType.ERROR); Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setContentText(msg); alert.setContentText(msg);
alert.showAndWait(); alert.showAndWait();
} }
/** /**
* Adds a recipe, by providing a default name "Untitled recipe (n)". * Adds a recipe, by providing a default name "Untitled recipe (n)".
* The UX flow is now: * The UX flow is now:
@ -283,7 +319,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
try { try {
server.updateRecipe(selected); server.updateRecipe(selected);
refresh(); refresh();
recipeList.getSelectionModel().select(selected); //recipeList.getSelectionModel().select(selected);
} catch (IOException | InterruptedException e) { } catch (IOException | InterruptedException e) {
// throw a nice blanket UpdateException // throw a nice blanket UpdateException
throw new UpdateException("Error occurred when updating recipe name!"); throw new UpdateException("Error occurred when updating recipe name!");
@ -294,7 +330,6 @@ public class FoodpalApplicationCtrl implements LocaleAware {
edit.requestFocus(); edit.requestFocus();
} }
// Language buttons // Language buttons
@FXML @FXML
private void switchLocale(ActionEvent event) { private void switchLocale(ActionEvent event) {

View file

@ -0,0 +1,111 @@
package client.utils;
import commons.ws.messages.Message;
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.messaging.simp.stomp.StompHeaders;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompFrameHandler;
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.messaging.WebSocketStompClient;
import java.lang.reflect.Type;
import java.util.function.Consumer;
import javax.annotation.Nullable;
import java.util.concurrent.CompletableFuture;
public class WebSocketUtils {
private static final String WS_URL = "ws://localhost:8080/updates";
private WebSocketStompClient stompClient;
private StompSession stompSession;
/**
* Connect to the websocket server.
* @param onConnected OnConnected callback
*/
public void connect(@Nullable Runnable onConnected) {
StandardWebSocketClient webSocketClient = new StandardWebSocketClient(); // Create WS Client
stompClient = new WebSocketStompClient(webSocketClient);
stompClient.setMessageConverter(new MappingJackson2MessageConverter()); // Use jackson
CompletableFuture<StompSession> sessionFuture = stompClient.connectAsync(
WS_URL,
new StompSessionHandlerAdapter() {
@Override
public void afterConnected(StompSession session, StompHeaders connectedHeaders) {
stompSession = session;
System.out.println("WebSocket connected: " + session.getSessionId());
}
@Override
public void handleException(StompSession session, @Nullable StompCommand command,
StompHeaders headers, byte[] payload,
Throwable exception) {
System.err.println("STOMP error: " + exception.getMessage());
exception.printStackTrace();
}
@Override
public void handleTransportError(StompSession session, Throwable exception) {
System.err.println("STOMP transport error: " + exception.getMessage());
exception.printStackTrace();
}
}
);
sessionFuture.thenAccept(session -> {
stompSession = session;
System.out.println("Connection successful, session ready");
if (onConnected != null) {
onConnected.run();
}
}).exceptionally(throwable -> {
System.err.println("Failed to connect: " + throwable.getMessage());
throwable.printStackTrace();
return null;
});
}
/**
* Subscribe to a topic.
* @param destination Destination to subscribe to, for example: /subscribe/recipe
* @param messageHandler Handler for received messages
*/
public void subscribe(String destination, Consumer<Message> messageHandler) {
if (stompSession == null || !stompSession.isConnected()) {
System.err.println("Cannot subscribe - not connected");
return;
}
stompSession.subscribe(destination, new StompFrameHandler() {
@Override
public Type getPayloadType(StompHeaders headers) {
return Message.class;
}
@Override
public void handleFrame(StompHeaders headers, @Nullable Object payload) {
Message message = (Message) payload;
messageHandler.accept(message);
}
});
System.out.println("Subscribed to: " + destination);
}
public void disconnect() {
if (stompSession != null && stompSession.isConnected()) {
stompSession.disconnect();
}
if (stompClient != null) {
stompClient.stop();
}
}
public boolean isConnected() {
return stompSession != null && stompSession.isConnected();
}
}

View file

@ -45,6 +45,12 @@
<version>${version.mockito}</version> <version>${version.mockito}</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.20.1</version>
<scope>compile</scope>
</dependency>
</dependencies> </dependencies>

View file

@ -10,6 +10,8 @@ import commons.Ingredient;
public class CreateIngredientMessage implements Message { public class CreateIngredientMessage implements Message {
private Ingredient ingredient; private Ingredient ingredient;
public CreateIngredientMessage() {} // for jackson
public CreateIngredientMessage(Ingredient ingredient) { public CreateIngredientMessage(Ingredient ingredient) {
this.ingredient = ingredient; this.ingredient = ingredient;
} }
@ -27,4 +29,9 @@ public class CreateIngredientMessage implements Message {
public Ingredient getIngredient() { public Ingredient getIngredient() {
return ingredient; return ingredient;
} }
// for jackson
public void setIngredient(Ingredient ingredient) {
this.ingredient = ingredient;
}
} }

View file

@ -10,6 +10,8 @@ import commons.Recipe;
public class CreateRecipeMessage implements Message { public class CreateRecipeMessage implements Message {
private Recipe recipe; private Recipe recipe;
public CreateRecipeMessage() {} // for jackson
public CreateRecipeMessage(Recipe recipe) { public CreateRecipeMessage(Recipe recipe) {
this.recipe = recipe; this.recipe = recipe;
} }
@ -27,4 +29,9 @@ public class CreateRecipeMessage implements Message {
public Recipe getRecipe() { public Recipe getRecipe() {
return recipe; return recipe;
} }
// for jackson
public void setRecipe(Recipe recipe) {
this.recipe = recipe;
}
} }

View file

@ -8,6 +8,8 @@ package commons.ws.messages;
public class DeleteIngredientMessage implements Message { public class DeleteIngredientMessage implements Message {
private Long ingredientId; private Long ingredientId;
public DeleteIngredientMessage() {} // for jackson
public DeleteIngredientMessage(Long ingredientId) { public DeleteIngredientMessage(Long ingredientId) {
this.ingredientId = ingredientId; this.ingredientId = ingredientId;
} }
@ -25,4 +27,9 @@ public class DeleteIngredientMessage implements Message {
public Long getIngredientId() { public Long getIngredientId() {
return ingredientId; return ingredientId;
} }
// for jackson
public void setIngredientId(Long ingredientId) {
this.ingredientId = ingredientId;
}
} }

View file

@ -8,6 +8,8 @@ package commons.ws.messages;
public class DeleteRecipeMessage implements Message { public class DeleteRecipeMessage implements Message {
private Long recipeId; private Long recipeId;
public DeleteRecipeMessage() {} // for jackson
public DeleteRecipeMessage(Long recipeId) { public DeleteRecipeMessage(Long recipeId) {
this.recipeId = recipeId; this.recipeId = recipeId;
} }
@ -25,4 +27,9 @@ public class DeleteRecipeMessage implements Message {
public Long getRecipeId() { public Long getRecipeId() {
return recipeId; return recipeId;
} }
// for jackson
public void setRecipeId(Long recipeId) {
this.recipeId = recipeId;
}
} }

View file

@ -1,5 +1,21 @@
package commons.ws.messages; package commons.ws.messages;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = CreateRecipeMessage.class, name = "RECIPE_CREATE"),
@JsonSubTypes.Type(value = UpdateRecipeMessage.class, name = "RECIPE_UPDATE"),
@JsonSubTypes.Type(value = DeleteRecipeMessage.class, name = "RECIPE_DELETE"),
@JsonSubTypes.Type(value = CreateIngredientMessage.class, name = "INGREDIENT_CREATE"),
@JsonSubTypes.Type(value = UpdateIngredientMessage.class, name = "INGREDIENT_UPDATE"),
@JsonSubTypes.Type(value = DeleteIngredientMessage.class, name = "INGREDIENT_DELETE")
})
public interface Message { public interface Message {
public enum Type { public enum Type {
/** /**

View file

@ -10,6 +10,8 @@ import commons.Ingredient;
public class UpdateIngredientMessage implements Message { public class UpdateIngredientMessage implements Message {
private Ingredient ingredient; private Ingredient ingredient;
public UpdateIngredientMessage() {} // for jackson
public UpdateIngredientMessage(Ingredient ingredient) { public UpdateIngredientMessage(Ingredient ingredient) {
this.ingredient = ingredient; this.ingredient = ingredient;
} }
@ -27,4 +29,9 @@ public class UpdateIngredientMessage implements Message {
public Ingredient getIngredient() { public Ingredient getIngredient() {
return ingredient; return ingredient;
} }
// for jackson
public void setIngredient(Ingredient ingredient) {
this.ingredient = ingredient;
}
} }

View file

@ -10,6 +10,8 @@ import commons.Recipe;
public class UpdateRecipeMessage implements Message { public class UpdateRecipeMessage implements Message {
private Recipe recipe; private Recipe recipe;
public UpdateRecipeMessage() {} // for jackson
public UpdateRecipeMessage(Recipe recipe) { public UpdateRecipeMessage(Recipe recipe) {
this.recipe = recipe; this.recipe = recipe;
} }
@ -27,4 +29,9 @@ public class UpdateRecipeMessage implements Message {
public Recipe getRecipe() { public Recipe getRecipe() {
return recipe; return recipe;
} }
// for jackson
public void setRecipe(Recipe recipe) {
this.recipe = recipe;
}
} }