diff --git a/flow-server/src/main/java/com/vaadin/flow/server/FutureAccess.java b/flow-server/src/main/java/com/vaadin/flow/server/FutureAccess.java index 86d9bf47c6e..ebf30f02c6c 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/FutureAccess.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/FutureAccess.java @@ -15,11 +15,14 @@ */ package com.vaadin.flow.server; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.FutureTask; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; + /** * Encapsulates a {@link Command} submitted using * {@link VaadinSession#access(Command)}. This class is used internally by the @@ -31,6 +34,9 @@ public class FutureAccess extends FutureTask { private final VaadinSession session; private final Command command; + private final Iterable interceptors; + private final Map context = new HashMap<>(); // TODO: + // ConcurrentHashMap? /** * Creates an instance for the given command. @@ -40,10 +46,19 @@ public class FutureAccess extends FutureTask { * @param command * the command to run when this task is purged from the queue */ - public FutureAccess(VaadinSession session, Command command) { + public FutureAccess(Iterable interceptors, + VaadinSession session, Command command) { super(command::execute, null); this.session = session; this.command = command; + this.interceptors = interceptors; + } + + @Override + public void run() { + this.interceptors.forEach(interceptor -> interceptor + .commandExecutionStart(context, command)); + super.run(); } @Override @@ -59,7 +74,10 @@ public Void get() throws InterruptedException, ExecutionException { * easier to detect potential problems. */ VaadinService.verifyNoOtherSessionLocked(session); - return super.get(); + Void unused = super.get(); + interceptors.forEach(interceptor -> interceptor + .commandExecutionEnd(context, command)); + return unused; } /** @@ -70,6 +88,8 @@ public Void get() throws InterruptedException, ExecutionException { */ public void handleError(Exception exception) { try { + interceptors.forEach(interceptor -> interceptor + .handleException(context, command, exception)); if (command instanceof ErrorHandlingCommand) { ErrorHandlingCommand errorHandlingCommand = (ErrorHandlingCommand) command; @@ -88,6 +108,9 @@ public void handleError(Exception exception) { } } catch (Exception e) { getLogger().error(e.getMessage(), e); + } finally { + interceptors.forEach(interceptor -> interceptor + .commandExecutionEnd(context, command)); } } diff --git a/flow-server/src/main/java/com/vaadin/flow/server/ServiceInitEvent.java b/flow-server/src/main/java/com/vaadin/flow/server/ServiceInitEvent.java index 0b80908112d..8056349fcf3 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/ServiceInitEvent.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/ServiceInitEvent.java @@ -39,6 +39,7 @@ public class ServiceInitEvent extends EventObject { private List addedIndexHtmlRequestListeners = new ArrayList<>(); private List addedDependencyFilters = new ArrayList<>(); private List addedVaadinRequestInterceptors = new ArrayList<>(); + private List addedVaadinCommandInterceptor = new ArrayList<>(); /** * Creates a new service init event for a given {@link VaadinService} and @@ -107,6 +108,20 @@ public void addVaadinRequestInterceptor( addedVaadinRequestInterceptors.add(vaadinRequestInterceptor); } + /** + * Adds a new command interceptor that will be used by this service. + * + * @param vaadinCommandInterceptor + * the interceptor to add, not null + */ + public void addVaadinCommandInterceptor( + VaadinCommandInterceptor vaadinCommandInterceptor) { + Objects.requireNonNull(vaadinCommandInterceptor, + "Command Interceptor cannot be null"); + + addedVaadinCommandInterceptor.add(vaadinCommandInterceptor); + } + /** * Gets a stream of all custom request handlers that have been added for the * service. @@ -147,6 +162,16 @@ public Stream getAddedVaadinRequestInterceptor() { return addedVaadinRequestInterceptors.stream(); } + /** + * Gets a stream of all Vaadin command interceptors that have been added for + * the service. + * + * @return the stream of added command interceptors + */ + public Stream getAddedVaadinCommandInterceptor() { + return addedVaadinCommandInterceptor.stream(); + } + @Override public VaadinService getSource() { return (VaadinService) super.getSource(); diff --git a/flow-server/src/main/java/com/vaadin/flow/server/VaadinCommandInterceptor.java b/flow-server/src/main/java/com/vaadin/flow/server/VaadinCommandInterceptor.java new file mode 100644 index 00000000000..6f178a6ef8f --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/VaadinCommandInterceptor.java @@ -0,0 +1,63 @@ +/* + * Copyright 2000-2023 Vaadin Ltd. + * + * Licensed 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 com.vaadin.flow.server; + +import java.io.Serializable; +import java.util.Map; + +/** + * Used to provide an around-like aspect option around command processing. + * + * @author Marcin Grzejszczak + * @since 24.2 + */ +public interface VaadinCommandInterceptor extends Serializable { + + /** + * Called when command is about to be started. + * + * @param context + * mutable map passed between methods of this interceptor + * @param command + * command + */ + void commandExecutionStart(Map context, Command command); + + /** + * Called when an exception occurred + * + * @param context + * mutable map passed between methods of this interceptor + * @param command + * command + * @param t + * exception + */ + void handleException(Map context, Command command, + Exception t); + + /** + * Called at the end of processing a command. Will be called regardless of + * whether there was an exception or not. + * + * @param context + * mutable map passed between methods of this interceptor + * @param command + * command + */ + void commandExecutionEnd(Map context, Command command); +} diff --git a/flow-server/src/main/java/com/vaadin/flow/server/VaadinService.java b/flow-server/src/main/java/com/vaadin/flow/server/VaadinService.java index a64a7fe2277..5fa332861c6 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/VaadinService.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/VaadinService.java @@ -181,6 +181,8 @@ public abstract class VaadinService implements Serializable { private Iterable vaadinRequestInterceptors; + private Iterable vaadinCommandInterceptors; + /** * Creates a new vaadin service based on a deployment configuration. * @@ -225,6 +227,7 @@ public void init() throws ServiceException { // list // and append ones from the ServiceInitEvent List requestInterceptors = createVaadinRequestInterceptors(); + List commandInterceptors = createVaadinCommandInterceptor(); ServiceInitEvent event = new ServiceInitEvent(this); @@ -248,6 +251,14 @@ public void init() throws ServiceException { vaadinRequestInterceptors = Collections .unmodifiableCollection(requestInterceptors); + event.getAddedVaadinCommandInterceptor() + .forEach(commandInterceptors::add); + + Collections.reverse(commandInterceptors); + + vaadinCommandInterceptors = Collections + .unmodifiableCollection(commandInterceptors); + dependencyFilters = Collections.unmodifiableCollection(instantiator .getDependencyFilters(event.getAddedDependencyFilters()) .collect(Collectors.toList())); @@ -352,6 +363,22 @@ protected List createVaadinRequestInterceptors() return new ArrayList<>(); } + /** + * Called during initialization to add the request handlers for the service. + * Note that the returned list will be reversed so the last interceptor will + * be called first. This enables overriding this method and using add on the + * returned list to add a custom interceptors which overrides any predefined + * handler. + * + * @return The list of request handlers used by this service. + * @throws ServiceException + * if a problem occurs when creating the request interceptors + */ + protected List createVaadinCommandInterceptor() + throws ServiceException { + return new ArrayList<>(); + } + /** * Creates an instantiator to use with this service. *

@@ -2004,7 +2031,8 @@ public static boolean isCsrfTokenValid(UI ui, String requestToken) { * @see VaadinSession#access(Command) */ public Future accessSession(VaadinSession session, Command command) { - FutureAccess future = new FutureAccess(session, command); + FutureAccess future = new FutureAccess(vaadinCommandInterceptors, + session, command); session.getPendingAccessQueue().add(future); ensureAccessQueuePurged(session); diff --git a/flow-server/src/test/java/com/vaadin/flow/component/UITest.java b/flow-server/src/test/java/com/vaadin/flow/component/UITest.java index cc9de2bd4b7..2160593bd42 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/UITest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/UITest.java @@ -16,31 +16,6 @@ package com.vaadin.flow.component; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -import org.hamcrest.CoreMatchers; -import org.hamcrest.MatcherAssert; -import org.junit.After; -import org.junit.Assert; -import org.junit.Ignore; -import org.junit.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.ArgumentMatchers; -import org.mockito.Mockito; -import org.slf4j.Logger; - import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; import com.vaadin.flow.component.page.History; import com.vaadin.flow.component.page.History.HistoryStateChangeEvent; @@ -78,10 +53,13 @@ import com.vaadin.flow.router.internal.AfterNavigationHandler; import com.vaadin.flow.router.internal.BeforeEnterHandler; import com.vaadin.flow.router.internal.BeforeLeaveHandler; +import com.vaadin.flow.server.Command; import com.vaadin.flow.server.InvalidRouteConfigurationException; import com.vaadin.flow.server.MockVaadinContext; import com.vaadin.flow.server.MockVaadinServletService; import com.vaadin.flow.server.MockVaadinSession; +import com.vaadin.flow.server.ServiceException; +import com.vaadin.flow.server.VaadinCommandInterceptor; import com.vaadin.flow.server.VaadinContext; import com.vaadin.flow.server.VaadinRequest; import com.vaadin.flow.server.VaadinResponse; @@ -91,6 +69,31 @@ import com.vaadin.flow.shared.Registration; import com.vaadin.tests.util.AlwaysLockedVaadinSession; import com.vaadin.tests.util.MockUI; +import org.hamcrest.CoreMatchers; +import org.hamcrest.MatcherAssert; +import org.junit.After; +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; +import org.slf4j.Logger; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; public class UITest { @@ -173,6 +176,30 @@ public void visit(StateNode node, NodeVisitor visitor) { } } + static class MyInterceptor implements VaadinCommandInterceptor { + + Map map; + + @Override + public void commandExecutionStart(Map context, + Command command) { + map = context; + context.put("observation.started", true); + } + + @Override + public void handleException(Map context, + Command command, Exception t) { + context.put("observation.error", t); + } + + @Override + public void commandExecutionEnd(Map context, + Command command) { + context.put("observation.end", true); + } + } + @After public void tearDown() { CurrentInstance.clearAll(); @@ -209,7 +236,13 @@ private static MockUI createAccessableTestUI() { } private static void initUI(UI ui, String initialLocation, - ArgumentCaptor statusCodeCaptor) + ArgumentCaptor statusCodeCaptor) { + initUI(ui, initialLocation, statusCodeCaptor, null); + } + + private static void initUI(UI ui, String initialLocation, + ArgumentCaptor statusCodeCaptor, + MyInterceptor myInterceptor) throws InvalidRouteConfigurationException { VaadinServletRequest request = Mockito.mock(VaadinServletRequest.class); VaadinResponse response = Mockito.mock(VaadinResponse.class); @@ -228,6 +261,15 @@ private static void initUI(UI ui, String initialLocation, public VaadinContext getContext() { return new MockVaadinContext(); } + + @Override + protected List createVaadinCommandInterceptor() + throws ServiceException { + if (myInterceptor != null) { + return Collections.singletonList(myInterceptor); + } + return super.createVaadinCommandInterceptor(); + } }; service.setCurrentInstances(request, response); @@ -608,6 +650,30 @@ public void unsetSession_accessErrorHandlerStillWorks() throws IOException { logOutputNoDebug.contains("UIDetachedException")); } + @Test + public void unsetSession_commandInterceptorGetsExecuted() + throws IOException { + MyInterceptor myInterceptor = new MyInterceptor(); + UI ui = createTestUI(); + initUI(ui, "", null, myInterceptor); + + ui.getSession().access(() -> ui.getInternals().setSession(null)); + ui.access(() -> { + Assert.fail("We should never get here because the UI is detached"); + }); + + // Unlock to run pending access tasks + ui.getSession().unlock(); + + Map map = myInterceptor.map; + Assert.assertTrue("Listener must be called on command start", + map.containsKey("observation.started")); + Assert.assertTrue("Listener must be called on command error", + map.containsKey("observation.error")); + Assert.assertTrue("Listener must be called on command end", + map.containsKey("observation.end")); + } + @Test public void beforeClientResponse_regularOrder() { UI ui = createTestUI(); diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/SpringBootAutoConfiguration.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/SpringBootAutoConfiguration.java index 1c6322f2854..2abe78e7765 100644 --- a/vaadin-spring/src/main/java/com/vaadin/flow/spring/SpringBootAutoConfiguration.java +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/SpringBootAutoConfiguration.java @@ -129,4 +129,4 @@ public ServerEndpointExporter websocketEndpointDeployer() { return new VaadinWebsocketEndpointExporter(); } -} +} \ No newline at end of file diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/service/TestServletConfiguration.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/service/TestServletConfiguration.java index 63ee5e9f0c0..5c3da9424c4 100644 --- a/vaadin-spring/src/test/java/com/vaadin/flow/spring/service/TestServletConfiguration.java +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/service/TestServletConfiguration.java @@ -15,15 +15,18 @@ */ package com.vaadin.flow.spring.service; +import com.vaadin.flow.server.Command; import com.vaadin.flow.server.VaadinRequest; -import com.vaadin.flow.server.VaadinRequestInterceptor; import com.vaadin.flow.server.VaadinResponse; import com.vaadin.flow.server.VaadinSession; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.SpringBootConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; +import java.util.Map; + @Configuration @ComponentScan @SpringBootConfiguration @@ -35,6 +38,12 @@ static class TestConfig { MyRequestInterceptor myFilter() { return new MyRequestInterceptor(); } + + @Bean + MyVaadinComandInterceptor myVaadinComandInterceptor() { + return new MyVaadinComandInterceptor(); + } + } static class MyRequestInterceptor implements VaadinRequestInterceptor { @@ -58,4 +67,25 @@ public void requestEnd(VaadinRequest request, VaadinResponse response, request.setAttribute("stopped", "true"); } } + + static class MyVaadinComandInterceptor implements VaadinCommandInterceptor { + + @Override + public void commandExecutionStart(Map context, + Command command) { + + } + + @Override + public void handleException(Map context, + Command command, Exception t) { + + } + + @Override + public void commandExecutionEnd(Map context, + Command command) { + + } + } } \ No newline at end of file