This is an automated email from the ASF dual-hosted git repository. dashorst pushed a commit to branch wicket-10.x in repository https://gitbox.apache.org/repos/asf/wicket.git
commit ce96b0578a46c07655b42cbd064136360b7952b6 Author: Emond Papegaaij <[email protected]> AuthorDate: Fri Sep 25 22:12:25 2026 +0200 Pass a client message to a WebSocketBehavior only where the client may invoke it A page has a single WebSocket connection, whichever of its behaviors opened it, and every text or binary message the client sends over it is delivered to every WebSocketBehavior of the page. WebSocketBehavior#onEvent handed such a message to onMessage() unconditionally, so a behavior of a component that is not visible or not enabled - including one the IAuthorizationStrategy does not allow to render or to be enabled, and one inside such a container - received the messages of the client, together with a WebSocketRequestHandler to answer them. An Ajax behavior of that same component is not invoked: ListenerRequestHandler asks Behavior#canCallListener(Component) first. onMessage() is the WebSocket counterpart of such a listener, so ask the same question before calling it. A behavior that has to receive the messages of the client regardless overrides canCallListener(), exactly as an Ajax behavior does. Only the two onMessage() callbacks are affected. onPush(), onConnect(), onClose(), onError() and onAbort() are notifications of the server and keep reaching every behavior, so a component that is not visible can still be told to become visible. The event broadcast itself is unchanged: a component handling a WebSocketTextPayload in its own onEvent() receives it as it receives any other event, and the user guide now says that it has to make its own checks. Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../wicket/protocol/ws/api/WebSocketBehavior.java | 65 ++++- .../WebSocketBehaviorCanCallListenerTest.java | 281 +++++++++++++++++++++ .../nativewebsockets/nativewebsockets_2.adoc | 4 + 3 files changed, 344 insertions(+), 6 deletions(-) diff --git a/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/WebSocketBehavior.java b/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/WebSocketBehavior.java index 30a65e034b..72ee2b5059 100644 --- a/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/WebSocketBehavior.java +++ b/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/WebSocketBehavior.java @@ -33,15 +33,34 @@ import org.apache.wicket.protocol.ws.api.message.ConnectedMessage; import org.apache.wicket.protocol.ws.api.message.ErrorMessage; import org.apache.wicket.protocol.ws.api.message.IWebSocketPushMessage; import org.apache.wicket.protocol.ws.api.message.TextMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * A behavior that provides optional callbacks for the WebSocket * messages (connect, message, close) + * <p> + * A page has a single WebSocket connection, whichever of its behaviors opened it, and a message + * the client sends over it is delivered to every WebSocketBehavior of the page. A message sent by + * the client is passed to {@link #onMessage(WebSocketRequestHandler, TextMessage)} or + * {@link #onMessage(WebSocketRequestHandler, BinaryMessage)} only when + * {@link #canCallListener(Component)} allows it for the component this behavior is attached to, + * the same check that decides whether the client may invoke an Ajax behavior of that component. + * By default, therefore, a behavior of a component that is not visible or not enabled - including + * one the {@link org.apache.wicket.authorization.IAuthorizationStrategy} does not allow to render + * or to be enabled - does not receive the messages of the client. Override + * {@link #canCallListener(Component)} to receive them regardless. + * </p> + * <p> + * The other callbacks are notifications of the server and are always called. + * </p> * * @since 6.0 */ public abstract class WebSocketBehavior extends BaseWebSocketBehavior { + private static final Logger LOG = LoggerFactory.getLogger(WebSocketBehavior.class); + public WebSocketBehavior() { } @@ -59,15 +78,21 @@ public abstract class WebSocketBehavior extends BaseWebSocketBehavior if (payload instanceof WebSocketTextPayload) { - WebSocketTextPayload textPayload = (WebSocketTextPayload) payload; - TextMessage data = textPayload.getMessage(); - onMessage(webSocketHandler, data); + if (canReceiveClientMessage(component)) + { + WebSocketTextPayload textPayload = (WebSocketTextPayload) payload; + TextMessage data = textPayload.getMessage(); + onMessage(webSocketHandler, data); + } } else if (wsPayload instanceof WebSocketBinaryPayload) { - WebSocketBinaryPayload binaryPayload = (WebSocketBinaryPayload) wsPayload; - BinaryMessage binaryData = binaryPayload.getMessage(); - onMessage(webSocketHandler, binaryData); + if (canReceiveClientMessage(component)) + { + WebSocketBinaryPayload binaryPayload = (WebSocketBinaryPayload) wsPayload; + BinaryMessage binaryData = binaryPayload.getMessage(); + onMessage(webSocketHandler, binaryData); + } } else if (wsPayload instanceof WebSocketConnectedPayload) { @@ -102,6 +127,26 @@ public abstract class WebSocketBehavior extends BaseWebSocketBehavior } } + /** + * Decides whether a message sent by the client is passed on to this behavior, with the check + * that decides whether the client may invoke a listener of it. + * + * @param component + * the component this behavior is attached to + * @return {@code true} if {@link #canCallListener(Component)} allows it + */ + private boolean canReceiveClientMessage(Component component) + { + if (canCallListener(component)) + { + return true; + } + + LOG.debug("A message of the client is not passed to {} of component '{}', because " + + "canCallListener() does not allow it.", getClass().getName(), component.getPageRelativePath()); + return false; + } + /** * A callback method called when there is a message pushed/broadcasted by the * server, e.g. pushed by a backend service @@ -160,6 +205,10 @@ public abstract class WebSocketBehavior extends BaseWebSocketBehavior /** * A callback method called when there is a text message sent by the client + * <p> + * It is only called when {@link #canCallListener(Component)} allows it for the component this + * behavior is attached to. + * </p> * * @param handler * The request handler that can be used to send messages back to the client @@ -172,6 +221,10 @@ public abstract class WebSocketBehavior extends BaseWebSocketBehavior /** * A callback method called when there is a binary message sent by the client + * <p> + * It is only called when {@link #canCallListener(Component)} allows it for the component this + * behavior is attached to. + * </p> * * @param handler * The request handler that can be used to send messages back to the client diff --git a/wicket-native-websocket/wicket-native-websocket-tester/src/test/java/org/apache/wicket/protocol/ws/tester/WebSocketBehaviorCanCallListenerTest.java b/wicket-native-websocket/wicket-native-websocket-tester/src/test/java/org/apache/wicket/protocol/ws/tester/WebSocketBehaviorCanCallListenerTest.java new file mode 100644 index 0000000000..8eb2954582 --- /dev/null +++ b/wicket-native-websocket/wicket-native-websocket-tester/src/test/java/org/apache/wicket/protocol/ws/tester/WebSocketBehaviorCanCallListenerTest.java @@ -0,0 +1,281 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.protocol.ws.tester; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.apache.wicket.Component; +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.authroles.authorization.strategies.role.RoleAuthorizationStrategy; +import org.apache.wicket.authroles.authorization.strategies.role.Roles; +import org.apache.wicket.authroles.authorization.strategies.role.metadata.MetaDataRoleAuthorizationStrategy; +import org.apache.wicket.event.IEvent; +import org.apache.wicket.markup.IMarkupResourceStreamProvider; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.protocol.ws.api.WebSocketBehavior; +import org.apache.wicket.protocol.ws.api.WebSocketRequestHandler; +import org.apache.wicket.protocol.ws.api.event.WebSocketTextPayload; +import org.apache.wicket.protocol.ws.api.message.BinaryMessage; +import org.apache.wicket.protocol.ws.api.message.ConnectedMessage; +import org.apache.wicket.protocol.ws.api.message.IWebSocketPushMessage; +import org.apache.wicket.protocol.ws.api.message.TextMessage; +import org.apache.wicket.protocol.ws.api.registry.PageIdKey; +import org.apache.wicket.util.resource.IResourceStream; +import org.apache.wicket.util.resource.StringResourceStream; +import org.apache.wicket.util.tester.WicketTester; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * A message sent by the client reaches {@link WebSocketBehavior#onMessage} only where the client + * may invoke a listener of the behavior, just like an Ajax behavior. What the server pushes, and + * the event broadcast itself, are not affected. + */ +class WebSocketBehaviorCanCallListenerTest +{ + /** what reached which callback, as "callback:component id" */ + private static final List<String> RECEIVED = new CopyOnWriteArrayList<>(); + + private WicketTester tester; + + /** + * Records what reaches it, under the id of its component. + */ + private static class RecordingBehavior extends WebSocketBehavior + { + private String componentId; + + @Override + public void bind(Component component) + { + super.bind(component); + componentId = component.getId(); + } + + @Override + protected void onMessage(WebSocketRequestHandler handler, TextMessage message) + { + RECEIVED.add("text:" + componentId); + } + + @Override + protected void onMessage(WebSocketRequestHandler handler, BinaryMessage binaryMessage) + { + RECEIVED.add("binary:" + componentId); + } + + @Override + protected void onConnect(ConnectedMessage message) + { + RECEIVED.add("connect:" + componentId); + } + + @Override + protected void onPush(WebSocketRequestHandler handler, IWebSocketPushMessage message) + { + RECEIVED.add("push:" + componentId); + } + } + + private static class PushMessage implements IWebSocketPushMessage + { + } + + /** + * A page with a behavior on the page and on containers in every state that matters. + */ + public static class TestPage extends WebPage implements IMarkupResourceStreamProvider + { + public TestPage() + { + add(new RecordingBehavior()); + + add(container("visible")); + + WebMarkupContainer hidden = container("hidden"); + hidden.setVisible(false); + hidden.add(container("hiddenChild")); + add(hidden); + + WebMarkupContainer disabled = container("disabled"); + disabled.setEnabled(false); + add(disabled); + + WebMarkupContainer renderDenied = container("renderDenied"); + MetaDataRoleAuthorizationStrategy.authorize(renderDenied, RENDER, "ADMIN"); + add(renderDenied); + + WebMarkupContainer enableDenied = container("enableDenied"); + MetaDataRoleAuthorizationStrategy.authorize(enableDenied, ENABLE, "ADMIN"); + add(enableDenied); + + WebMarkupContainer optedIn = new WebMarkupContainer("optedIn"); + optedIn.setVisible(false); + optedIn.add(new RecordingBehavior() + { + @Override + public boolean canCallListener(Component component) + { + return true; + } + }); + add(optedIn); + + // a component handling the payload itself receives it as every event broadcast does + add(new WebMarkupContainer("ownOnEvent") + { + @Override + public void onEvent(IEvent<?> event) + { + if (event.getPayload() instanceof WebSocketTextPayload) + { + RECEIVED.add("onEvent:" + getId()); + } + } + }.setVisible(false)); + } + + private static WebMarkupContainer container(String id) + { + WebMarkupContainer container = new WebMarkupContainer(id); + container.add(new RecordingBehavior()); + return container; + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class<?> containerClass) + { + return new StringResourceStream("<html><head></head><body>" + + "<div wicket:id=\"visible\"></div>" + + "<div wicket:id=\"hidden\"><div wicket:id=\"hiddenChild\"></div></div>" + + "<div wicket:id=\"disabled\"></div>" + + "<div wicket:id=\"renderDenied\"></div>" + + "<div wicket:id=\"enableDenied\"></div>" + + "<div wicket:id=\"optedIn\"></div>" + + "<div wicket:id=\"ownOnEvent\"></div>" + + "</body></html>"); + } + } + + @BeforeEach + void before() + { + RECEIVED.clear(); + + tester = new WicketTester(); + tester.getApplication() + .getSecuritySettings() + .setAuthorizationStrategy( + new RoleAuthorizationStrategy(roles -> roles.hasAnyRole(new Roles("USER")))); + } + + @AfterEach + void after() + { + tester.destroy(); + } + + private static Set<String> expected(String callback, String... componentIds) + { + Set<String> expected = new TreeSet<>(); + for (String componentId : componentIds) + { + expected.add(callback + ":" + componentId); + } + return expected; + } + + private static Set<String> received() + { + return new TreeSet<>(RECEIVED); + } + + @Test + void aTextMessageReachesOnlyTheBehaviorsTheClientMayInvoke() + { + TestPage page = tester.startPage(TestPage.class); + + WebSocketTester webSocketTester = new WebSocketTester(tester, page); + try + { + RECEIVED.clear(); + webSocketTester.sendMessage("a message"); + + Set<String> expected = expected("text", page.getId(), "visible", "optedIn"); + expected.add("onEvent:ownOnEvent"); + assertEquals(expected, received()); + } + finally + { + webSocketTester.destroy(); + } + } + + @Test + void aBinaryMessageReachesOnlyTheBehaviorsTheClientMayInvoke() + { + TestPage page = tester.startPage(TestPage.class); + + WebSocketTester webSocketTester = new WebSocketTester(tester, page); + try + { + RECEIVED.clear(); + byte[] message = "a message".getBytes(StandardCharsets.UTF_8); + webSocketTester.sendMessage(message, 0, message.length); + + assertEquals(expected("binary", page.getId(), "visible", "optedIn"), received()); + } + finally + { + webSocketTester.destroy(); + } + } + + @Test + void theNotificationsOfTheServerReachEveryBehavior() + { + TestPage page = tester.startPage(TestPage.class); + + WebSocketTester webSocketTester = new WebSocketTester(tester, page); + try + { + String[] everyBehavior = { page.getId(), "visible", "hidden", "hiddenChild", "disabled", + "renderDenied", "enableDenied", "optedIn" }; + + assertEquals(expected("connect", everyBehavior), received()); + + RECEIVED.clear(); + webSocketTester.broadcast(tester.getApplication(), tester.getHttpSession().getId(), + new PageIdKey(page.getPageId(), page.getClass().getName()), new PushMessage()); + + assertEquals(expected("push", everyBehavior), received()); + } + finally + { + webSocketTester.destroy(); + } + } +} diff --git a/wicket-user-guide/src/main/asciidoc/nativewebsockets/nativewebsockets_2.adoc b/wicket-user-guide/src/main/asciidoc/nativewebsockets/nativewebsockets_2.adoc index 9110779f61..fc7f62a311 100644 --- a/wicket-user-guide/src/main/asciidoc/nativewebsockets/nativewebsockets_2.adoc +++ b/wicket-user-guide/src/main/asciidoc/nativewebsockets/nativewebsockets_2.adoc @@ -84,6 +84,10 @@ public class MyPage extends WebPage { Use _message.getText()_ to read the message sent by the client and use _handler.push(String)_ to push a text message to the connected client. Additionally you can use _handler.add(Component...)_ to add Wicket components for re-render, _handler.prependJavaScript(CharSequence)_ and _handler.appendJavaScript(CharSequence)_ as you do with _AjaxRequestTarget_. +A page has a single WebSocket connection, whichever of its behaviors opened it, and a message sent by the client over it is delivered to every _WebSocketBehavior_ of the page. Like an Ajax behavior, a _WebSocketBehavior_ is passed the messages of the client only when _canCallListener(Component)_ allows it for the component it is attached to, so by default a behavior of a component that is not visible or not enabled - including one the _IAuthorizationStrategy_ does not allow to render or [...] + +The messages are also sent as an event to every component of the page, as a _WebSocketTextPayload_ or a _WebSocketBinaryPayload_, and like any other event broadcast this reaches components that are not visible or not enabled too. A component that handles these payloads in its own _onEvent()_ receives what the client sent and has to make any checks itself. + * *WebSocketResource* Wicket allows one thread at a time to use a page instance to simplify the usage of the pages in multithreaded environment. When a WebSocket message is sent to a page Wicket needs to acquire the lock to that page to be able to pass the _IWebSocketMessage_ to the _WebSocketBehavior_. This may be problematic when the application needs to send many messages from the client to the server.
