From 4eb6dc7dd1f674d00aad579101a8d9334ded0a36 Mon Sep 17 00:00:00 2001 From: Alice <7364785+liquidnya@users.noreply.github.com> Date: Tue, 18 Jun 2024 13:53:40 +0200 Subject: [PATCH] Add root path for Quinoa Web UI (#691) * Add quarkus.quinoa.ui-root-path This adds the quarkus.quinoa.ui-root-path property which is the path for hosting the Web UI. The quarkus.quinoa.ignored-path-prefixes property is always relative to quarkus.quinoa.ui-root-path. The quarkus.http.non-application-root-path is not added to the default ignores if it is not relative to quarkus.http.root-path. * Add tests for ignored paths * Add integration test for quarkus.quinoa.ui-root-path * Add trailing slash to ui root path log * Fix ignored paths --------- Co-authored-by: Melloware --- .../deployment/ForwardedDevProcessor.java | 14 ++- .../quinoa/deployment/QuinoaProcessor.java | 19 +++- .../deployment/config/QuinoaConfig.java | 101 ++++++++++++++--- .../config/delegate/QuinoaConfigDelegate.java | 5 + ...refixesRESTConfigRelativeRootPathTest.java | 42 +++++++ ...noaPathPrefixesRESTConfigRootPathTest.java | 42 +++++++ .../QuinoaPathPrefixesRESTConfigTest.java | 4 +- ...aPathPrefixesRESTConfigUiRootPathTest.java | 41 +++++++ .../ROOT/pages/includes/quarkus-quinoa.adoc | 21 +++- .../src/main/resources/application.properties | 23 ++-- .../src/main/ui-lit/src/simple-greeting.js | 4 +- .../src/main/ui-lit/webpack.config.js | 2 +- .../quinoa/it/QuinoaUiRootPathTest.java | 106 ++++++++++++++++++ .../quarkiverse/quinoa/it/TestProfiles.java | 7 ++ .../io/quarkiverse/quinoa/QuinoaRecorder.java | 35 +++++- .../quinoa/QuinoaSPARoutingHandler.java | 12 +- 16 files changed, 434 insertions(+), 44 deletions(-) create mode 100644 deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigRelativeRootPathTest.java create mode 100644 deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigRootPathTest.java create mode 100644 deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigUiRootPathTest.java create mode 100644 integration-tests/src/test/java/io/quarkiverse/quinoa/it/QuinoaUiRootPathTest.java diff --git a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/ForwardedDevProcessor.java b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/ForwardedDevProcessor.java index c52168e2..f6f07986 100644 --- a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/ForwardedDevProcessor.java +++ b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/ForwardedDevProcessor.java @@ -50,6 +50,8 @@ import io.quarkus.resteasy.reactive.server.spi.ResumeOn404BuildItem; import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.vertx.core.deployment.CoreVertxBuildItem; +import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem; +import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; import io.quarkus.vertx.http.deployment.RouteBuildItem; import io.quarkus.vertx.http.deployment.WebsocketSubProtocolsBuildItem; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; @@ -189,6 +191,8 @@ public void runtimeInit( Optional devProxy, Optional configuredQuinoa, CoreVertxBuildItem vertx, + HttpRootPathBuildItem httpRootPath, + NonApplicationRootPathBuildItem nonApplicationRootPath, BuildProducer routes, BuildProducer websocketSubProtocols, BuildProducer resumeOn404) throws IOException { @@ -200,12 +204,16 @@ public void runtimeInit( return; } LOG.infof("Quinoa is forwarding unhandled requests to port: %d", devProxy.get().getPort()); - final QuinoaDevProxyHandlerConfig handlerConfig = toDevProxyHandlerConfig(quinoaConfig, httpBuildTimeConfig); + final QuinoaDevProxyHandlerConfig handlerConfig = toDevProxyHandlerConfig(quinoaConfig, httpBuildTimeConfig, + nonApplicationRootPath); + String uiRootPath = QuinoaConfig.getNormalizedUiRootPath(quinoaConfig); + recorder.logUiRootPath(httpRootPath.relativePath(uiRootPath)); final QuinoaNetworkConfiguration networkConfig = new QuinoaNetworkConfiguration(devProxy.get().isTls(), devProxy.get().isTlsAllowInsecure(), devProxy.get().getHost(), devProxy.get().getPort(), quinoaConfig.devServer().websocket()); - routes.produce(RouteBuildItem.builder().orderedRoute("/*", QUINOA_ROUTE_ORDER) + // note that the uiRootPath is resolved relative to 'quarkus.http.root-path' by the RouteBuildItem + routes.produce(RouteBuildItem.builder().orderedRoute(uiRootPath + "*", QUINOA_ROUTE_ORDER) .handler(recorder.quinoaProxyDevHandler(handlerConfig, vertx.getVertx(), networkConfig)) .build()); if (quinoaConfig.devServer().websocket()) { @@ -213,7 +221,7 @@ public void runtimeInit( } if (quinoaConfig.enableSPARouting()) { resumeOn404.produce(new ResumeOn404BuildItem()); - routes.produce(RouteBuildItem.builder().orderedRoute("/*", QUINOA_SPA_ROUTE_ORDER) + routes.produce(RouteBuildItem.builder().orderedRoute(uiRootPath + "*", QUINOA_SPA_ROUTE_ORDER) .handler(recorder.quinoaSPARoutingHandler(handlerConfig.ignoredPathPrefixes)) .build()); } diff --git a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/QuinoaProcessor.java b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/QuinoaProcessor.java index f38c15ee..49c4b968 100644 --- a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/QuinoaProcessor.java +++ b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/QuinoaProcessor.java @@ -50,6 +50,8 @@ import io.quarkus.deployment.util.FileUtil; import io.quarkus.runtime.LaunchMode; import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem; +import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; import io.quarkus.vertx.http.deployment.RouteBuildItem; import io.quarkus.vertx.http.deployment.spi.GeneratedStaticResourceBuildItem; @@ -227,9 +229,13 @@ public void produceGeneratedStaticResources( return; } if (uiResources.isPresent() && !uiResources.get().resources().isEmpty()) { + String uiRootPath = QuinoaConfig.getNormalizedUiRootPath(configuredQuinoa.resolvedConfig()); for (BuiltResourcesBuildItem.BuiltResource resource : uiResources.get().resources()) { + // note how uiRootPath always starts and ends in a slash + // and resource.name() always starts in a slash, therfore resource.name().substring(1) never starts in a slash generatedStaticResourceProducer - .produce(new GeneratedStaticResourceBuildItem(resource.name(), resource.content())); + .produce(new GeneratedStaticResourceBuildItem(uiRootPath + resource.name().substring(1), + resource.content())); } } } @@ -238,6 +244,8 @@ public void produceGeneratedStaticResources( @Record(RUNTIME_INIT) public void runtimeInit( ConfiguredQuinoaBuildItem configuredQuinoa, + HttpRootPathBuildItem httpRootPath, + NonApplicationRootPathBuildItem nonApplicationRootPath, QuinoaRecorder recorder, BuildProducer routes, Optional uiResources) throws IOException { @@ -245,10 +253,15 @@ public void runtimeInit( return; } if (uiResources.isPresent() && !uiResources.get().resources().isEmpty()) { + String uiRootPath = QuinoaConfig.getNormalizedUiRootPath(configuredQuinoa.resolvedConfig()); + // the resolvedUiRootPath is only used for logging + String resolvedUiRootPath = httpRootPath.relativePath(uiRootPath); + recorder.logUiRootPath(resolvedUiRootPath.endsWith("/") ? resolvedUiRootPath : resolvedUiRootPath + "/"); if (Objects.requireNonNull(configuredQuinoa).resolvedConfig().enableSPARouting()) { - routes.produce(RouteBuildItem.builder().orderedRoute("/*", QUINOA_SPA_ROUTE_ORDER) + routes.produce(RouteBuildItem.builder().orderedRoute(uiRootPath + "*", QUINOA_SPA_ROUTE_ORDER) .handler(recorder - .quinoaSPARoutingHandler(getNormalizedIgnoredPathPrefixes(configuredQuinoa.resolvedConfig()))) + .quinoaSPARoutingHandler(getNormalizedIgnoredPathPrefixes(configuredQuinoa.resolvedConfig(), + nonApplicationRootPath))) .build()); } } diff --git a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/config/QuinoaConfig.java b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/config/QuinoaConfig.java index 0d0c0431..be466d23 100644 --- a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/config/QuinoaConfig.java +++ b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/config/QuinoaConfig.java @@ -1,20 +1,21 @@ package io.quarkiverse.quinoa.deployment.config; -import static java.util.stream.Collectors.toList; - import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.ConfigProvider; import io.quarkiverse.quinoa.QuinoaDevProxyHandlerConfig; +import io.quarkus.deployment.util.UriNormalizationUtil; import io.quarkus.runtime.annotations.ConfigDocDefault; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; +import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; import io.smallrye.config.ConfigMapping; import io.smallrye.config.WithDefault; @@ -26,6 +27,7 @@ public interface QuinoaConfig { String DEFAULT_BUILD_DIR = "build/"; + String DEFAULT_WEB_UI_ROOT_PATH = "/"; String DEFAULT_WEB_UI_DIR = "src/main/webui"; String DEFAULT_INDEX_PAGE = "index.html"; @@ -46,6 +48,13 @@ public interface QuinoaConfig { @WithDefault("false") boolean justBuild(); + /** + * Root path for hosting the Web UI. + * This path is normalized and always resolved relative to 'quarkus.http.root-path'. + */ + @WithDefault(DEFAULT_WEB_UI_ROOT_PATH) + String uiRootPath(); + /** * Path to the Web UI (NodeJS) root directory (relative to the project root). */ @@ -115,8 +124,9 @@ public interface QuinoaConfig { /** * List of path prefixes to be ignored by Quinoa (SPA Handler and Dev-Proxy). + * The paths are normalized and always resolved relative to 'quarkus.quinoa.ui-root-path'. */ - @ConfigDocDefault("ignore values configured by 'quarkus.resteasy-reactive.path', 'quarkus.resteasy.path' and 'quarkus.http.non-application-root-path'") + @ConfigDocDefault("ignore values configured by 'quarkus.resteasy-reactive.path', 'quarkus.rest.path', 'quarkus.resteasy.path' and 'quarkus.http.non-application-root-path'") Optional> ignoredPathPrefixes(); /** @@ -124,30 +134,84 @@ public interface QuinoaConfig { */ DevServerConfig devServer(); - static List getNormalizedIgnoredPathPrefixes(QuinoaConfig config) { - return config.ignoredPathPrefixes().orElseGet(() -> { - Config allConfig = ConfigProvider.getConfig(); - List defaultIgnore = new ArrayList<>(); - readExternalConfigPath(allConfig, "quarkus.resteasy.path").ifPresent(defaultIgnore::add); - readExternalConfigPath(allConfig, "quarkus.rest.path").ifPresent(defaultIgnore::add); - readExternalConfigPath(allConfig, "quarkus.resteasy-reactive.path").ifPresent(defaultIgnore::add); - readExternalConfigPath(allConfig, "quarkus.http.non-application-root-path").ifPresent(defaultIgnore::add); - return defaultIgnore; - }).stream().map(s -> s.startsWith("/") ? s : "/" + s).collect(toList()); + static List getNormalizedIgnoredPathPrefixes(QuinoaConfig config, + NonApplicationRootPathBuildItem nonApplicationRootPath) { + return config.ignoredPathPrefixes() + .map(list -> list.stream() + .map(s -> normalizePath(s, false)) + .collect(Collectors.toList())) + .orElseGet(() -> { + Config allConfig = ConfigProvider.getConfig(); + List defaultIgnore = new ArrayList<>(); + String uiRootPath = getNormalizedUiRootPath(config); + // note that quarkus.resteasy.path and quarkus.resteasy-reactive.path are always relative to the http root path + readExternalConfigPath(uiRootPath, allConfig, "quarkus.resteasy.path").ifPresent(defaultIgnore::add); + readExternalConfigPath(uiRootPath, allConfig, "quarkus.rest.path").ifPresent(defaultIgnore::add); + readExternalConfigPath(uiRootPath, allConfig, "quarkus.resteasy-reactive.path") + .ifPresent(defaultIgnore::add); + // the non-application root path is not always relative to the http root path + convertNonApplicationRootPath(uiRootPath, nonApplicationRootPath).ifPresent(defaultIgnore::add); + return defaultIgnore; + }); } static QuinoaDevProxyHandlerConfig toDevProxyHandlerConfig(final QuinoaConfig config, - final HttpBuildTimeConfig httpBuildTimeConfig) { + final HttpBuildTimeConfig httpBuildTimeConfig, final NonApplicationRootPathBuildItem nonApplicationRootPath) { final Set compressMediaTypes = httpBuildTimeConfig.compressMediaTypes.map(Set::copyOf).orElse(Set.of()); - return new QuinoaDevProxyHandlerConfig(getNormalizedIgnoredPathPrefixes(config), + return new QuinoaDevProxyHandlerConfig(getNormalizedIgnoredPathPrefixes(config, nonApplicationRootPath), config.devServer().indexPage().orElse(DEFAULT_INDEX_PAGE), httpBuildTimeConfig.enableCompression, compressMediaTypes, config.devServer().directForwarding()); } - private static Optional readExternalConfigPath(Config config, String key) { + /** + *

+ * Normalizes the {@link QuinoaConfig#uiRootPath()} and the returned path always starts with {@code "/"} and ends with + * {@code "/"}. + *

+ * Note that this will not resolve the path relative to 'quarkus.http.root-path'. + */ + static String getNormalizedUiRootPath(QuinoaConfig config) { + return normalizePath(config.uiRootPath(), true); + } + + /** + * Normalizes the path and the returned path starts with a slash and if {@code trailingSlash} is set to {@code true} then it + * will also end in a slash. + */ + private static String normalizePath(String path, boolean trailingSlash) { + String normalizedPath = UriNormalizationUtil.toURI(path, trailingSlash).getPath(); + return normalizedPath.startsWith("/") ? normalizedPath : "/" + normalizedPath; + } + + /** + * Note that {@code rootPath} and {@code leafPath} are required to start and end in a slash. + * The returned path also fulfills this requirement. + */ + private static Optional relativizePath(String rootPath, String leafPath) { + return Optional.ofNullable(UriNormalizationUtil.relativize(rootPath, leafPath)) + // note that relativize always removes the leading slash + .map(s -> "/" + s); + } + + private static Optional readExternalConfigPath(String uiRootPath, Config config, String key) { return config.getOptionalValue(key, String.class) + .map(s -> normalizePath(s, true)) + // only add this path if it is relative to the ui-root-path + .flatMap(s -> relativizePath(uiRootPath, s)) .filter(s -> !Objects.equals(s, "/")) - .map(s -> s.endsWith("/") ? s : s + "/"); + .map(s -> s.endsWith("/") ? s.substring(0, s.length() - 1) : s); + } + + private static Optional convertNonApplicationRootPath(String uiRootPath, + NonApplicationRootPathBuildItem nonApplicationRootPath) { + // only add the non-application root path if it is relative to the http root path + // note that both paths start and end in a slash already + return relativizePath(nonApplicationRootPath.getNormalizedHttpRootPath(), + nonApplicationRootPath.getNonApplicationRootPath()) + // and also only add this path if it is relative to the ui-root-path + .flatMap(s -> relativizePath(uiRootPath, s)) + .filter(s -> !Objects.equals(s, "/")) + .map(s -> s.endsWith("/") ? s.substring(0, s.length() - 1) : s); } static boolean isDevServerMode(QuinoaConfig config) { @@ -165,6 +229,9 @@ static boolean isEqual(QuinoaConfig q1, QuinoaConfig q2) { if (!Objects.equals(q1.justBuild(), q2.justBuild())) { return false; } + if (!Objects.equals(q1.uiRootPath(), q2.uiRootPath())) { + return false; + } if (!Objects.equals(q1.uiDir(), q2.uiDir())) { return false; } diff --git a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/config/delegate/QuinoaConfigDelegate.java b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/config/delegate/QuinoaConfigDelegate.java index b7b3dfbf..1b52393a 100644 --- a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/config/delegate/QuinoaConfigDelegate.java +++ b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/config/delegate/QuinoaConfigDelegate.java @@ -26,6 +26,11 @@ public boolean justBuild() { return delegate.justBuild(); } + @Override + public String uiRootPath() { + return delegate.uiRootPath(); + } + @Override public String uiDir() { return delegate.uiDir(); diff --git a/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigRelativeRootPathTest.java b/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigRelativeRootPathTest.java new file mode 100644 index 00000000..f1d0efc8 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigRelativeRootPathTest.java @@ -0,0 +1,42 @@ +package io.quarkiverse.quinoa.test; + +import static io.quarkiverse.quinoa.deployment.testing.QuinoaQuarkusUnitTest.getWebUITestDirPath; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkiverse.quinoa.deployment.testing.QuinoaQuarkusUnitTest; +import io.quarkus.test.QuarkusUnitTest; + +public class QuinoaPathPrefixesRESTConfigRelativeRootPathTest { + + private static final String NAME = "resteasy-reactive-path-config-relative-root-path"; + + @RegisterExtension + static final QuarkusUnitTest config = QuinoaQuarkusUnitTest.create(NAME) + .toQuarkusUnitTest() + .overrideConfigKey("quarkus.http.root-path", "root/path") + .overrideConfigKey("quarkus.rest.path", "foo/reactive") + .overrideConfigKey("quarkus.resteasy.path", "foo/classic") + .overrideConfigKey("quarkus.http.non-application-root-path", "bar/non") + .overrideConfigKey("quarkus.quinoa.enable-spa-routing", "true") + .assertLogRecords(l -> assertThat(l) + .anyMatch(s -> s.getMessage() + // note how /bar/non is part of the ignored paths + // this is because bar/non is relative to the root path when it does not start with a slash + // also note that quarkus.rest.path, and quarkus.resteasy.path are always relative to the root path even if they start with a slash + .equals("Quinoa SPA routing handler is ignoring paths starting with: /foo/classic, /foo/reactive, /bar/non")) + .anyMatch(s -> s.getMessage() + .equals("Quinoa is available at: /root/path/"))); + + @Test + public void testQuinoa() { + assertThat(Path.of("target/quinoa/build/index.html")).isRegularFile() + .hasContent("test"); + assertThat(getWebUITestDirPath(NAME).resolve("node_modules/installed")).isRegularFile() + .hasContent("hello"); + } +} diff --git a/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigRootPathTest.java b/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigRootPathTest.java new file mode 100644 index 00000000..8b5ed2e2 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigRootPathTest.java @@ -0,0 +1,42 @@ +package io.quarkiverse.quinoa.test; + +import static io.quarkiverse.quinoa.deployment.testing.QuinoaQuarkusUnitTest.getWebUITestDirPath; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkiverse.quinoa.deployment.testing.QuinoaQuarkusUnitTest; +import io.quarkus.test.QuarkusUnitTest; + +public class QuinoaPathPrefixesRESTConfigRootPathTest { + + private static final String NAME = "resteasy-reactive-path-config-root-path"; + + @RegisterExtension + static final QuarkusUnitTest config = QuinoaQuarkusUnitTest.create(NAME) + .toQuarkusUnitTest() + .overrideConfigKey("quarkus.http.root-path", "/root/path") + .overrideConfigKey("quarkus.rest.path", "/foo/reactive") + .overrideConfigKey("quarkus.resteasy.path", "/foo/classic") + .overrideConfigKey("quarkus.http.non-application-root-path", "/bar/non") + .overrideConfigKey("quarkus.quinoa.enable-spa-routing", "true") + .assertLogRecords(l -> assertThat(l) + .anyMatch(s -> s.getMessage() + // note how /bar/non is not part of the ignored paths + // this is because /bar/non is not relative to /root/path + // also note that quarkus.rest.path, and quarkus.resteasy.path are always relative to the root path even if they start with a slash + .equals("Quinoa SPA routing handler is ignoring paths starting with: /foo/classic, /foo/reactive")) + .anyMatch(s -> s.getMessage() + .equals("Quinoa is available at: /root/path/"))); + + @Test + public void testQuinoa() { + assertThat(Path.of("target/quinoa/build/index.html")).isRegularFile() + .hasContent("test"); + assertThat(getWebUITestDirPath(NAME).resolve("node_modules/installed")).isRegularFile() + .hasContent("hello"); + } +} diff --git a/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigTest.java b/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigTest.java index 77bee2b0..f679a595 100644 --- a/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigTest.java +++ b/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigTest.java @@ -24,7 +24,9 @@ public class QuinoaPathPrefixesRESTConfigTest { .overrideConfigKey("quarkus.quinoa.enable-spa-routing", "true") .assertLogRecords(l -> assertThat(l) .anyMatch(s -> s.getMessage() - .equals("Quinoa SPA routing handler is ignoring paths starting with: /foo/classic/, /foo/reactive/, /bar/non/"))); + .equals("Quinoa SPA routing handler is ignoring paths starting with: /foo/classic, /foo/reactive, /bar/non")) + .anyMatch(s -> s.getMessage() + .equals("Quinoa is available at: /"))); @Test public void testQuinoa() { diff --git a/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigUiRootPathTest.java b/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigUiRootPathTest.java new file mode 100644 index 00000000..8a6b3f9b --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/quinoa/test/QuinoaPathPrefixesRESTConfigUiRootPathTest.java @@ -0,0 +1,41 @@ +package io.quarkiverse.quinoa.test; + +import static io.quarkiverse.quinoa.deployment.testing.QuinoaQuarkusUnitTest.getWebUITestDirPath; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkiverse.quinoa.deployment.testing.QuinoaQuarkusUnitTest; +import io.quarkus.test.QuarkusUnitTest; + +public class QuinoaPathPrefixesRESTConfigUiRootPathTest { + + private static final String NAME = "resteasy-reactive-path-config-ui-root-path"; + + @RegisterExtension + static final QuarkusUnitTest config = QuinoaQuarkusUnitTest.create(NAME) + .toQuarkusUnitTest() + .overrideConfigKey("quarkus.http.root-path", "/root/path") + .overrideConfigKey("quarkus.quinoa.ui-root-path", "/foo") + .overrideConfigKey("quarkus.rest.path", "/foo/reactive") + .overrideConfigKey("quarkus.resteasy.path", "/foo/classic") + .overrideConfigKey("quarkus.http.non-application-root-path", "/bar/non") + .overrideConfigKey("quarkus.quinoa.enable-spa-routing", "true") + .assertLogRecords(l -> assertThat(l) + .anyMatch(s -> s.getMessage() + // ignored paths are always relative to the ui root path + .equals("Quinoa SPA routing handler is ignoring paths starting with: /classic, /reactive")) + .anyMatch(s -> s.getMessage() + .equals("Quinoa is available at: /root/path/foo/"))); + + @Test + public void testQuinoa() { + assertThat(Path.of("target/quinoa/build/index.html")).isRegularFile() + .hasContent("test"); + assertThat(getWebUITestDirPath(NAME).resolve("node_modules/installed")).isRegularFile() + .hasContent("hello"); + } +} diff --git a/docs/modules/ROOT/pages/includes/quarkus-quinoa.adoc b/docs/modules/ROOT/pages/includes/quarkus-quinoa.adoc index 96c8f2e2..8fa9b470 100644 --- a/docs/modules/ROOT/pages/includes/quarkus-quinoa.adoc +++ b/docs/modules/ROOT/pages/includes/quarkus-quinoa.adoc @@ -44,6 +44,23 @@ endif::add-copy-button-to-env-var[] |`false` +a|icon:lock[title=Fixed at build time] [[quarkus-quinoa_quarkus-quinoa-ui-root-path]]`link:#quarkus-quinoa_quarkus-quinoa-ui-root-path[quarkus.quinoa.ui-root-path]` + + +[.description] +-- +Root path for hosting the Web UI. This path is normalized and always resolved relative to 'quarkus.http.root-path'. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_QUINOA_UI_ROOT_PATH+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_QUINOA_UI_ROOT_PATH+++` +endif::add-copy-button-to-env-var[] +--|string +|`/` + + a|icon:lock[title=Fixed at build time] [[quarkus-quinoa_quarkus-quinoa-ui-dir]]`link:#quarkus-quinoa_quarkus-quinoa-ui-dir[quarkus.quinoa.ui-dir]` @@ -474,7 +491,7 @@ a|icon:lock[title=Fixed at build time] [[quarkus-quinoa_quarkus-quinoa-ignored-p [.description] -- -List of path prefixes to be ignored by Quinoa (SPA Handler and Dev-Proxy). +List of path prefixes to be ignored by Quinoa (SPA Handler and Dev-Proxy). The paths are normalized and always resolved relative to 'quarkus.quinoa.ui-root-path'. ifdef::add-copy-button-to-env-var[] Environment variable: env_var_with_copy_button:+++QUARKUS_QUINOA_IGNORED_PATH_PREFIXES+++[] @@ -483,7 +500,7 @@ ifndef::add-copy-button-to-env-var[] Environment variable: `+++QUARKUS_QUINOA_IGNORED_PATH_PREFIXES+++` endif::add-copy-button-to-env-var[] --|list of string -|`ignore values configured by 'quarkus.resteasy-reactive.path', 'quarkus.resteasy.path' and 'quarkus.http.non-application-root-path'` +|`ignore values configured by 'quarkus.resteasy-reactive.path', 'quarkus.rest.path', 'quarkus.resteasy.path' and 'quarkus.http.non-application-root-path'` a|icon:lock[title=Fixed at build time] [[quarkus-quinoa_quarkus-quinoa-dev-server]]`link:#quarkus-quinoa_quarkus-quinoa-dev-server[quarkus.quinoa.dev-server]` diff --git a/integration-tests/src/main/resources/application.properties b/integration-tests/src/main/resources/application.properties index 326f1106..0ff9007c 100644 --- a/integration-tests/src/main/resources/application.properties +++ b/integration-tests/src/main/resources/application.properties @@ -1,6 +1,12 @@ -%lit-root-path.quarkus.http.root-path=/foo/bar -%lit-root-path.quarkus.quinoa.enable-spa-routing=true -%lit-root-path.quarkus.quinoa.package-manager-command.build-env.ROOT_PATH=/foo/bar/ +%lit-root-path,lit-ui-root-path.quarkus.http.root-path=/foo/bar +%lit-root-path,lit-ui-root-path.quarkus.quinoa.enable-spa-routing=true +%lit-root-path,lit-ui-root-path.quarkus.quinoa.package-manager-command.build-env.API_PATH=/foo/bar/api/ + +%lit-root-path.quarkus.quinoa.package-manager-command.build-env.ROOT_PATH=/foo/bar + +%lit-ui-root-path.quarkus.quinoa.ui-root-path=/ui +%lit-ui-root-path.quarkus.quinoa.package-manager-command.build-env.ROOT_PATH=/foo/bar/ui +%lit-ui-root-path.quarkus.quinoa.ignored-path-prefixes=/ignored quarkus.quinoa.ui-dir=src/main/ui-react %react.quarkus.quinoa.ui-dir=src/main/ui-react @@ -13,13 +19,14 @@ quarkus.quinoa.ui-dir=src/main/ui-react %angular.quarkus.quinoa.package-manager-install.install-dir=target/ %angular.quarkus.quinoa.package-manager-install.node-version=20.9.0 %angular.quarkus.quinoa.package-manager-install.yarn-version=1.22.19 -%lit,lit-root-path.quarkus.quinoa.ui-dir=src/main/ui-lit -%lit,lit-root-path.quarkus.quinoa.build-dir=dist -%lit,lit-root-path.quarkus.http.static-resources.index-page=app.html -%lit,lit-root-path.quarkus.quinoa.package-manager-command.build=run build-per-env -%lit-root-path.quarkus.quinoa.package-manager-command.build-env.FOO=bar +%lit,lit-root-path,lit-ui-root-path.quarkus.quinoa.ui-dir=src/main/ui-lit +%lit,lit-root-path,lit-ui-root-path.quarkus.quinoa.build-dir=dist +%lit,lit-root-path,lit-ui-root-path.quarkus.http.static-resources.index-page=app.html +%lit,lit-root-path,lit-ui-root-path.quarkus.quinoa.package-manager-command.build=run build-per-env +%lit-root-path,lit-ui-root-path.quarkus.quinoa.package-manager-command.build-env.FOO=bar %lit.quarkus.quinoa.package-manager-command.build-env.FOO=bar %lit.quarkus.quinoa.package-manager-command.build-env.ROOT_PATH=/ +%lit.quarkus.quinoa.package-manager-command.build-env.API_PATH=/api/ %yarn.quarkus.quinoa.package-manager=yarn %just-build.quarkus.quinoa.just-build=true diff --git a/integration-tests/src/main/ui-lit/src/simple-greeting.js b/integration-tests/src/main/ui-lit/src/simple-greeting.js index c75d91c1..79adab00 100644 --- a/integration-tests/src/main/ui-lit/src/simple-greeting.js +++ b/integration-tests/src/main/ui-lit/src/simple-greeting.js @@ -1,6 +1,6 @@ import {html, css, LitElement} from 'lit'; -const ROOT_PATH = process.env.ROOT_PATH; +const API_PATH = process.env.API_PATH; export class SimpleGreeting extends LitElement { static styles = css`p { color: blue }`; @@ -17,7 +17,7 @@ export class SimpleGreeting extends LitElement { render() { const xmlHttp = new XMLHttpRequest(); - xmlHttp.open( "GET", `${ROOT_PATH}api/quinoa`, false ); + xmlHttp.open( "GET", `${API_PATH}quinoa`, false ); xmlHttp.send( null ); const response = xmlHttp.responseText; return html`

${response} and ${this.name} and ${process.env.FOO}

`; diff --git a/integration-tests/src/main/ui-lit/webpack.config.js b/integration-tests/src/main/ui-lit/webpack.config.js index 48cc13f6..57806f33 100644 --- a/integration-tests/src/main/ui-lit/webpack.config.js +++ b/integration-tests/src/main/ui-lit/webpack.config.js @@ -12,6 +12,6 @@ module.exports = { { from: 'public' } ] }), - new EnvironmentPlugin(['FOO', 'ROOT_PATH']) + new EnvironmentPlugin(['FOO', 'ROOT_PATH', 'API_PATH']) ] }; \ No newline at end of file diff --git a/integration-tests/src/test/java/io/quarkiverse/quinoa/it/QuinoaUiRootPathTest.java b/integration-tests/src/test/java/io/quarkiverse/quinoa/it/QuinoaUiRootPathTest.java new file mode 100644 index 00000000..2f7b251b --- /dev/null +++ b/integration-tests/src/test/java/io/quarkiverse/quinoa/it/QuinoaUiRootPathTest.java @@ -0,0 +1,106 @@ +package io.quarkiverse.quinoa.it; + +import static io.restassured.RestAssured.given; + +import java.net.URL; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.ElementHandle; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Response; + +import io.quarkiverse.playwright.InjectPlaywright; +import io.quarkiverse.playwright.WithPlaywright; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +@QuarkusTest +@TestProfile(TestProfiles.UiRootPathTests.class) +@WithPlaywright +public class QuinoaUiRootPathTest { + + @InjectPlaywright + BrowserContext context; + + @TestHTTPResource("/") + URL url404Root; + + @TestHTTPResource("/ui/ignored") + URL url404Ignored; + + @TestHTTPResource("/ui/ignored/sub-path") + URL url404IgnoredSubPath; + + // this path starts with /ignored, but is not a sub path of /ignored + @TestHTTPResource("/ui/ignored-not-ignored") + URL urlNotIgnored; + + // note how the path "/ui" does not work in a production build + // note that "/ui" works in dev-mode as long as the nodejs server also hosts "/ui" + @TestHTTPResource("/ui/") + URL url; + + @TestHTTPResource("/ui/some-route") + URL urlRoute; + + @Test + public void test404Endpoints() { + given() + .when().get(url404Root) + .then() + .statusCode(404); + given() + .when().get(url404Ignored) + .then() + .statusCode(404); + given() + .when().get(url404IgnoredSubPath) + .then() + .statusCode(404); + } + + @Test + public void testUIIndex() { + final Page page = context.newPage(); + Response response = page.navigate(url.toString()); + Assertions.assertEquals("OK", response.statusText()); + + page.waitForLoadState(); + + String title = page.title(); + Assertions.assertEquals("Quinoa Lit App", title); + + // Make sure the component loaded and hits the backend + final ElementHandle quinoaEl = page.waitForSelector(".greeting"); + String greeting = quinoaEl.innerText(); + Assertions.assertEquals("Hello Quinoa and World and bar", greeting); + } + + @Test + public void testRoute() { + final Page page = context.newPage(); + Response response = page.navigate(urlRoute.toString()); + Assertions.assertEquals("OK", response.statusText()); + + page.waitForLoadState(); + + String title = page.title(); + Assertions.assertEquals("Quinoa Lit App", title); + } + + @Test + public void testNotIgnored() { + final Page page = context.newPage(); + Response response = page.navigate(urlNotIgnored.toString()); + Assertions.assertEquals("OK", response.statusText()); + + page.waitForLoadState(); + + String title = page.title(); + Assertions.assertEquals("Quinoa Lit App", title); + } +} diff --git a/integration-tests/src/test/java/io/quarkiverse/quinoa/it/TestProfiles.java b/integration-tests/src/test/java/io/quarkiverse/quinoa/it/TestProfiles.java index 0f512cd4..6a65d0e9 100644 --- a/integration-tests/src/test/java/io/quarkiverse/quinoa/it/TestProfiles.java +++ b/integration-tests/src/test/java/io/quarkiverse/quinoa/it/TestProfiles.java @@ -46,6 +46,13 @@ public String getConfigProfile() { } } + public static class UiRootPathTests extends QuinoaTestProfiles.Enable { + @Override + public String getConfigProfile() { + return "lit-ui-root-path"; + } + } + public static class VueTests extends QuinoaTestProfiles.Enable { @Override public String getConfigProfile() { diff --git a/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaRecorder.java b/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaRecorder.java index c11a2e11..3027053b 100644 --- a/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaRecorder.java +++ b/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaRecorder.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.function.Supplier; @@ -28,7 +29,7 @@ public class QuinoaRecorder { public Handler quinoaProxyDevHandler(final QuinoaDevProxyHandlerConfig handlerConfig, Supplier vertx, QuinoaNetworkConfiguration network) { if (LOG.isDebugEnabled()) { - LOG.debugf("Quinoa dev proxy-handler is ignoring paths starting with: " + LOG.debug("Quinoa dev proxy-handler is ignoring paths starting with: " + String.join(", ", handlerConfig.ignoredPathPrefixes)); } return new QuinoaDevProxyHandler(handlerConfig, vertx.get(), network); @@ -36,22 +37,46 @@ public Handler quinoaProxyDevHandler(final QuinoaDevProxyHandler public Handler quinoaSPARoutingHandler(List ignoredPathPrefixes) throws IOException { if (LOG.isDebugEnabled()) { - LOG.debugf("Quinoa SPA routing handler is ignoring paths starting with: " + String.join(", ", ignoredPathPrefixes)); + LOG.debug("Quinoa SPA routing handler is ignoring paths starting with: " + String.join(", ", ignoredPathPrefixes)); } return new QuinoaSPARoutingHandler(ignoredPathPrefixes); } + public void logUiRootPath(final String resolvedUiRootPath) { + if (LOG.isDebugEnabled()) { + LOG.debug("Quinoa is available at: " + resolvedUiRootPath); + } + } + static String resolvePath(RoutingContext ctx) { - return (ctx.mountPoint() == null) ? ctx.normalizedPath() + // quarkus.http.root-path + String path = (ctx.mountPoint() == null) ? ctx.normalizedPath() : ctx.normalizedPath().substring( // let's be extra careful here in case Vert.x normalizes the mount points at // some point ctx.mountPoint().endsWith("/") ? ctx.mountPoint().length() - 1 : ctx.mountPoint().length()); + // quarkus.quinoa.ui-root-path + String routePath = ctx.currentRoute().getPath(); + String resolvedPath = (routePath == null) ? path + : path.substring(routePath.endsWith("/") ? routePath.length() - 1 : routePath.length()); + // use "/" when the path is empty + // e.g. this happens when the request path is "/example" and the root path is "/example" + return resolvedPath.isEmpty() ? "/" : resolvedPath; + } + + static boolean matchesPathSeparatedPrefix(String path, String pathSeparatedPrefix) { + if (path.startsWith(pathSeparatedPrefix)) { + String restPath = path.substring(pathSeparatedPrefix.length()); + // the path matches the path separated prefix if the rest path is empty or starts with "/" + // note that the pathSeparatedPrefix never ends in "/" except if it equals "/" exactly + return restPath.isEmpty() || restPath.startsWith("/") || Objects.equals(pathSeparatedPrefix, "/"); + } + return false; } static boolean isIgnored(final String path, final List ignoredPathPrefixes) { - if (ignoredPathPrefixes.stream().anyMatch(path::startsWith)) { - LOG.debugf("Quinoa is ignoring path (quarkus.quinoa.ignored-path-prefixes): " + path); + if (ignoredPathPrefixes.stream().anyMatch(prefix -> matchesPathSeparatedPrefix(path, prefix))) { + LOG.debug("Quinoa is ignoring path (quarkus.quinoa.ignored-path-prefixes): " + path); return true; } return false; diff --git a/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaSPARoutingHandler.java b/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaSPARoutingHandler.java index ec42a562..d32f3720 100644 --- a/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaSPARoutingHandler.java +++ b/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaSPARoutingHandler.java @@ -31,8 +31,16 @@ public void handle(RoutingContext ctx) { } String path = resolvePath(ctx); if (!Objects.equals(path, "/") && !isIgnored(path, ignoredPathPrefixes)) { - LOG.debugf("Quinoa is re-routing SPA request '%s' to '/'", ctx.normalizedPath()); - ctx.reroute(ctx.mountPoint() != null ? ctx.mountPoint() : "/"); + String mountPoint = ctx.mountPoint() != null ? ctx.mountPoint() : "/"; + String routePath = ctx.currentRoute().getPath() != null ? ctx.currentRoute().getPath() : "/"; + String target; + if (mountPoint.endsWith("/")) { + target = mountPoint.substring(0, mountPoint.length() - 1) + routePath; + } else { + target = mountPoint + routePath; + } + LOG.debugf("Quinoa is re-routing SPA request '%s' to '%s'", ctx.normalizedPath(), target); + ctx.reroute(target); } else { next(currentClassLoader, ctx); }