diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListener.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListener.java index c1252c9db6..228617b5fd 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListener.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListener.java @@ -35,6 +35,8 @@ import com.vaadin.flow.server.menu.MenuRegistry; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.core.JsonProcessingException; + import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -63,55 +65,20 @@ public class RouteUnifyingIndexHtmlRequestListener private static final Logger LOGGER = LoggerFactory .getLogger(RouteUnifyingIndexHtmlRequestListener.class); - private final NavigationAccessControl accessControl; - private final DeploymentConfiguration deploymentConfiguration; - private final boolean exposeServerRoutesToClient; - private final ObjectMapper mapper = new ObjectMapper(); - private final ViewAccessChecker viewAccessChecker; - /** - * Creates a new listener instance with the given route registry. - * - * @param deploymentConfiguration - * the runtime deployment configuration - * @param exposeServerRoutesToClient - * whether to expose server routes to the client - */ - public RouteUnifyingIndexHtmlRequestListener( - DeploymentConfiguration deploymentConfiguration, - @Nullable NavigationAccessControl accessControl, - @Nullable ViewAccessChecker viewAccessChecker, - boolean exposeServerRoutesToClient) { - this.deploymentConfiguration = deploymentConfiguration; - this.accessControl = accessControl; - this.viewAccessChecker = viewAccessChecker; - this.exposeServerRoutesToClient = exposeServerRoutesToClient; + private ServerAndClientViewsProvider serverAndClientViewsProvider; - mapper.addMixIn(AvailableViewInfo.class, IgnoreMixin.class); + public RouteUnifyingIndexHtmlRequestListener( + ServerAndClientViewsProvider serverAndClientViewsProvider) { + this.serverAndClientViewsProvider = serverAndClientViewsProvider; } @Override public void modifyIndexHtmlResponse(IndexHtmlResponse response) { - final Map availableViews = new HashMap<>( - collectClientViews(response.getVaadinRequest())); - final boolean hasMainMenuRoute = hasMainMenu( - MenuRegistry.collectClientMenuItems(true, - deploymentConfiguration, response.getVaadinRequest())); - if (exposeServerRoutesToClient) { - LOGGER.debug( - "Exposing server-side views to the client based on user configuration"); - availableViews.putAll(collectServerViews(hasMainMenuRoute)); - } - - if (availableViews.isEmpty()) { - LOGGER.debug( - "No server-side nor client-side views found, skipping response modification."); - return; - } try { - final String fileRoutesJson = mapper - .writeValueAsString(availableViews); - final String script = SCRIPT_STRING.formatted(fileRoutesJson); + final String script = SCRIPT_STRING + .formatted(serverAndClientViewsProvider + .createFileRoutesJson(response.getVaadinRequest())); response.getDocument().head().appendElement("script") .appendChild(new DataNode(script)); } catch (IOException e) { @@ -121,123 +88,4 @@ public void modifyIndexHtmlResponse(IndexHtmlResponse response) { } } - protected Map collectClientViews( - VaadinRequest request) { - - final Map viewInfoMap = MenuRegistry - .collectClientMenuItems(true, deploymentConfiguration, request); - - final Set clientViewEntries = new HashSet<>( - viewInfoMap.keySet()); - for (var path : clientViewEntries) { - if (!viewInfoMap.containsKey(path)) { - continue; - } - - var viewInfo = viewInfoMap.get(path); - // Remove routes with required parameters, including nested ones - if (hasRequiredParameter(viewInfo)) { - viewInfoMap.remove(path); - if (viewInfo.children() != null) { - RouteUtil.removeChildren(viewInfoMap, viewInfo, path); - } - continue; - } - - // Remove layouts - if (viewInfo.children() != null) { - viewInfoMap.remove(path); - } - } - - return viewInfoMap; - } - - private boolean hasRequiredParameter(AvailableViewInfo viewInfo) { - final Map routeParameters = viewInfo - .routeParameters(); - if (routeParameters != null && !routeParameters.isEmpty() - && routeParameters.values().stream().anyMatch( - paramType -> paramType == RouteParamType.REQUIRED)) { - return true; - } - - // Nested routes could have parameters on the parent, check them also - final AvailableViewInfo parentViewInfo = null; - if (parentViewInfo != null) { - return hasRequiredParameter(parentViewInfo); - } - - return false; - } - - protected Map collectServerViews( - boolean hasMainMenu) { - final var vaadinService = VaadinService.getCurrent(); - if (vaadinService == null) { - LOGGER.debug( - "No VaadinService found, skipping server view collection"); - return Collections.emptyMap(); - } - final var serverRouteRegistry = vaadinService.getRouter().getRegistry(); - - var accessControls = Stream.of(accessControl, viewAccessChecker) - .filter(Objects::nonNull).toList(); - - var serverRoutes = new HashMap(); - - if (vaadinService.getInstantiator().getMenuAccessControl() - .getPopulateClientSideMenu() == MenuAccessControl.PopulateClientMenu.ALWAYS - || hasMainMenu) { - MenuRegistry.collectAndAddServerMenuItems( - RouteConfiguration.forRegistry(serverRouteRegistry), - accessControls, serverRoutes); - } - - return serverRoutes.values().stream() - .filter(view -> view.routeParameters().values().stream() - .noneMatch(param -> param == RouteParamType.REQUIRED)) - .collect(Collectors.toMap(this::getMenuLink, - Function.identity())); - } - - private boolean hasMainMenu(Map availableViews) { - Map clientItems = new HashMap<>( - availableViews); - - Set clientEntries = new HashSet<>(clientItems.keySet()); - for (String key : clientEntries) { - if (!clientItems.containsKey(key)) { - continue; - } - AvailableViewInfo viewInfo = clientItems.get(key); - if (viewInfo.children() != null) { - RouteUtil.removeChildren(clientItems, viewInfo, key); - } - } - return !clientItems.isEmpty() && clientItems.size() == 1 - && clientItems.values().iterator().next().route().equals(""); - } - - /** - * Gets menu link with omitted route parameters. - * - * @param info - * the menu item's target view - * @return target path for menu link - */ - private String getMenuLink(AvailableViewInfo info) { - final var parameterNames = info.routeParameters().keySet(); - return Stream.of(info.route().split("/")) - .filter(Predicate.not(parameterNames::contains)) - .collect(Collectors.joining("/")); - } - - /** - * Mixin to ignore unwanted fields in the json results. - */ - abstract static class IgnoreMixin { - @JsonIgnore - abstract List children(); // we don't need it! - } } diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/ServerAndClientViewsProvider.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/ServerAndClientViewsProvider.java new file mode 100644 index 0000000000..73cbb1e7ac --- /dev/null +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/ServerAndClientViewsProvider.java @@ -0,0 +1,201 @@ +package com.vaadin.hilla.route; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; + +import com.vaadin.flow.function.DeploymentConfiguration; +import com.vaadin.flow.router.RouteConfiguration; +import com.vaadin.flow.server.VaadinRequest; +import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.server.auth.MenuAccessControl; +import com.vaadin.flow.server.auth.NavigationAccessControl; +import com.vaadin.flow.server.auth.ViewAccessChecker; +import com.vaadin.flow.server.communication.IndexHtmlResponse; +import com.vaadin.flow.server.menu.AvailableViewInfo; +import com.vaadin.flow.server.menu.MenuRegistry; +import com.vaadin.flow.server.menu.RouteParamType; + +public class ServerAndClientViewsProvider { + + private final NavigationAccessControl accessControl; + private final DeploymentConfiguration deploymentConfiguration; + private final boolean exposeServerRoutesToClient; + private final ObjectMapper mapper = new ObjectMapper(); + private final ViewAccessChecker viewAccessChecker; + + private static final Logger LOGGER = LoggerFactory + .getLogger(ServerAndClientViewsProvider.class); + + /** + * Creates a new listener instance with the given route registry. + * + * @param deploymentConfiguration + * the runtime deployment configuration + * @param exposeServerRoutesToClient + * whether to expose server routes to the client + */ + public ServerAndClientViewsProvider( + DeploymentConfiguration deploymentConfiguration, + @Nullable NavigationAccessControl accessControl, + @Nullable ViewAccessChecker viewAccessChecker, + boolean exposeServerRoutesToClient) { + this.deploymentConfiguration = deploymentConfiguration; + this.accessControl = accessControl; + this.viewAccessChecker = viewAccessChecker; + this.exposeServerRoutesToClient = exposeServerRoutesToClient; + + mapper.addMixIn(AvailableViewInfo.class, IgnoreMixin.class); + } + + public String createFileRoutesJson(VaadinRequest request) + throws JsonProcessingException { + final Map availableViews = new HashMap<>( + collectClientViews(request)); + final boolean hasMainMenuRoute = hasMainMenu( + MenuRegistry.collectClientMenuItems(true, + deploymentConfiguration, request)); + if (exposeServerRoutesToClient) { + LOGGER.debug( + "Exposing server-side views to the client based on user configuration"); + availableViews.putAll(collectServerViews(hasMainMenuRoute)); + } + + return mapper.writeValueAsString(availableViews); + } + + protected Map collectClientViews( + VaadinRequest request) { + + final Map viewInfoMap = MenuRegistry + .collectClientMenuItems(true, deploymentConfiguration, request); + + final Set clientViewEntries = new HashSet<>( + viewInfoMap.keySet()); + for (var path : clientViewEntries) { + if (!viewInfoMap.containsKey(path)) { + continue; + } + + var viewInfo = viewInfoMap.get(path); + // Remove routes with required parameters, including nested ones + if (hasRequiredParameter(viewInfo)) { + viewInfoMap.remove(path); + if (viewInfo.children() != null) { + RouteUtil.removeChildren(viewInfoMap, viewInfo, path); + } + continue; + } + + // Remove layouts + if (viewInfo.children() != null) { + viewInfoMap.remove(path); + } + } + + return viewInfoMap; + } + + private static boolean hasRequiredParameter(AvailableViewInfo viewInfo) { + final Map routeParameters = viewInfo + .routeParameters(); + if (routeParameters != null && !routeParameters.isEmpty() + && routeParameters.values().stream().anyMatch( + paramType -> paramType == RouteParamType.REQUIRED)) { + return true; + } + + // Nested routes could have parameters on the parent, check them also + final AvailableViewInfo parentViewInfo = null; + if (parentViewInfo != null) { + return hasRequiredParameter(parentViewInfo); + } + + return false; + } + + protected Map collectServerViews( + boolean hasMainMenu) { + final var vaadinService = VaadinService.getCurrent(); + if (vaadinService == null) { + LOGGER.debug( + "No VaadinService found, skipping server view collection"); + return Collections.emptyMap(); + } + final var serverRouteRegistry = vaadinService.getRouter().getRegistry(); + + var accessControls = Stream.of(accessControl, viewAccessChecker) + .filter(Objects::nonNull).toList(); + + var serverRoutes = new HashMap(); + + if (vaadinService.getInstantiator().getMenuAccessControl() + .getPopulateClientSideMenu() == MenuAccessControl.PopulateClientMenu.ALWAYS + || hasMainMenu) { + MenuRegistry.collectAndAddServerMenuItems( + RouteConfiguration.forRegistry(serverRouteRegistry), + accessControls, serverRoutes); + } + + return serverRoutes.values().stream() + .filter(view -> view.routeParameters().values().stream() + .noneMatch(param -> param == RouteParamType.REQUIRED)) + .collect(Collectors.toMap(this::getMenuLink, + Function.identity())); + } + + private boolean hasMainMenu(Map availableViews) { + Map clientItems = new HashMap<>( + availableViews); + + Set clientEntries = new HashSet<>(clientItems.keySet()); + for (String key : clientEntries) { + if (!clientItems.containsKey(key)) { + continue; + } + AvailableViewInfo viewInfo = clientItems.get(key); + if (viewInfo.children() != null) { + RouteUtil.removeChildren(clientItems, viewInfo, key); + } + } + return !clientItems.isEmpty() && clientItems.size() == 1 + && clientItems.values().iterator().next().route().equals(""); + } + + /** + * Gets menu link with omitted route parameters. + * + * @param info + * the menu item's target view + * @return target path for menu link + */ + private String getMenuLink(AvailableViewInfo info) { + final var parameterNames = info.routeParameters().keySet(); + return Stream.of(info.route().split("/")) + .filter(Predicate.not(parameterNames::contains)) + .collect(Collectors.joining("/")); + } + + /** + * Mixin to ignore unwanted fields in the json results. + */ + abstract static class IgnoreMixin { + @JsonIgnore + abstract List children(); // we don't need it! + } +} diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/startup/RouteUnifyingServiceInitListener.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/startup/RouteUnifyingServiceInitListener.java index 85ef0fc72f..c43a6284cc 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/startup/RouteUnifyingServiceInitListener.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/startup/RouteUnifyingServiceInitListener.java @@ -25,6 +25,7 @@ import com.vaadin.hilla.HillaStats; import com.vaadin.hilla.route.RouteUnifyingIndexHtmlRequestListener; import com.vaadin.hilla.route.RouteUtil; +import com.vaadin.hilla.route.ServerAndClientViewsProvider; import com.vaadin.hilla.route.RouteUnifyingConfigurationProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -77,10 +78,12 @@ public void serviceInit(ServiceInitEvent event) { deploymentConfiguration.isReactEnabled()); boolean hasHillaFsRoute = false; if (deploymentConfiguration.isReactEnabled()) { - var routeUnifyingIndexHtmlRequestListener = new RouteUnifyingIndexHtmlRequestListener( + var serverAndClientViewsProvider = new ServerAndClientViewsProvider( deploymentConfiguration, accessControl, viewAccessChecker, routeUnifyingConfigurationProperties .isExposeServerRoutesToClient()); + var routeUnifyingIndexHtmlRequestListener = new RouteUnifyingIndexHtmlRequestListener( + serverAndClientViewsProvider); var deploymentMode = deploymentConfiguration.isProductionMode() ? "PRODUCTION" : "DEVELOPMENT"; diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListenerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListenerTest.java index f3f41059e5..87fe4036e4 100644 --- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListenerTest.java +++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListenerTest.java @@ -65,6 +65,8 @@ public class RouteUnifyingIndexHtmlRequestListenerTest { @Rule public TemporaryFolder projectRoot = new TemporaryFolder(); + private ServerAndClientViewsProvider serverClientViewsProvider; + @Before public void setUp() throws IOException { vaadinService = Mockito.mock(VaadinService.class); @@ -79,8 +81,10 @@ public void setUp() throws IOException { deploymentConfiguration = Mockito.mock(DeploymentConfiguration.class); Mockito.when(vaadinService.getDeploymentConfiguration()) .thenReturn(deploymentConfiguration); - requestListener = new RouteUnifyingIndexHtmlRequestListener( + serverClientViewsProvider = new ServerAndClientViewsProvider( deploymentConfiguration, null, null, true); + requestListener = new RouteUnifyingIndexHtmlRequestListener( + serverClientViewsProvider); indexHtmlResponse = Mockito.mock(IndexHtmlResponse.class); vaadinRequest = Mockito.mock(VaadinRequest.class); @@ -371,7 +375,7 @@ public void should_collectServerViews() { .mockStatic(VaadinService.class)) { mocked.when(VaadinService::getCurrent).thenReturn(vaadinService); - views = requestListener.collectServerViews(true); + views = serverClientViewsProvider.collectServerViews(true); } MatcherAssert.assertThat(views, Matchers.aMapWithSize(4)); MatcherAssert.assertThat(views.get("/bar").title(), @@ -409,7 +413,8 @@ public void when_productionMode_should_collectClientViews() menuRegistry.when(() -> MenuRegistry.getClassLoader()) .thenReturn(mockClassLoader); mocked.when(VaadinService::getCurrent).thenReturn(vaadinService); - var views = requestListener.collectClientViews(vaadinRequest); + var views = serverClientViewsProvider + .collectClientViews(vaadinRequest); MatcherAssert.assertThat(views, Matchers.aMapWithSize(4)); } } @@ -426,7 +431,8 @@ public void when_developmentMode_should_collectClientViews() try (MockedStatic mocked = Mockito .mockStatic(VaadinService.class)) { mocked.when(VaadinService::getCurrent).thenReturn(vaadinService); - var views = requestListener.collectClientViews(vaadinRequest); + var views = serverClientViewsProvider + .collectClientViews(vaadinRequest); MatcherAssert.assertThat(views, Matchers.aMapWithSize(4)); } } @@ -451,8 +457,10 @@ public void when_exposeServerRoutesToClient_false_serverSideRoutesAreNotInRespon .thenReturn(true); Mockito.when(vaadinRequest.isUserInRole(Mockito.anyString())) .thenReturn(true); - var requestListener = new RouteUnifyingIndexHtmlRequestListener( + var serverClientViewsProvider = new ServerAndClientViewsProvider( deploymentConfiguration, null, null, false); + var requestListener = new RouteUnifyingIndexHtmlRequestListener( + serverClientViewsProvider); requestListener.modifyIndexHtmlResponse(indexHtmlResponse); } @@ -516,8 +524,10 @@ public void when_exposeServerRoutesToClient_noLayout_serverSideRoutesAreNotInRes .thenReturn(true); Mockito.when(vaadinRequest.isUserInRole(Mockito.anyString())) .thenReturn(true); - var requestListener = new RouteUnifyingIndexHtmlRequestListener( + var serverAndClientViewsProvider = new ServerAndClientViewsProvider( deploymentConfiguration, null, null, true); + var requestListener = new RouteUnifyingIndexHtmlRequestListener( + serverAndClientViewsProvider); requestListener.modifyIndexHtmlResponse(indexHtmlResponse); } @@ -574,8 +584,10 @@ public void when_exposeServerRoutesToClient_layoutExists_serverSideRoutesAreInRe .thenReturn(true); Mockito.when(vaadinRequest.isUserInRole(Mockito.anyString())) .thenReturn(true); - var requestListener = new RouteUnifyingIndexHtmlRequestListener( + var serverAndClientViewsProvider = new ServerAndClientViewsProvider( deploymentConfiguration, null, null, true); + var requestListener = new RouteUnifyingIndexHtmlRequestListener( + serverAndClientViewsProvider); requestListener.modifyIndexHtmlResponse(indexHtmlResponse); }