Skip to content

Commit

Permalink
Add root path for Quinoa Web UI (#691)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
liquidnya and melloware authored Jun 18, 2024
1 parent e0fc4bd commit 4eb6dc7
Show file tree
Hide file tree
Showing 16 changed files with 434 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -189,6 +191,8 @@ public void runtimeInit(
Optional<ForwardedDevServerBuildItem> devProxy,
Optional<ConfiguredQuinoaBuildItem> configuredQuinoa,
CoreVertxBuildItem vertx,
HttpRootPathBuildItem httpRootPath,
NonApplicationRootPathBuildItem nonApplicationRootPath,
BuildProducer<RouteBuildItem> routes,
BuildProducer<WebsocketSubProtocolsBuildItem> websocketSubProtocols,
BuildProducer<ResumeOn404BuildItem> resumeOn404) throws IOException {
Expand All @@ -200,20 +204,24 @@ 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()) {
websocketSubProtocols.produce(new WebsocketSubProtocolsBuildItem("*"));
}
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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()));
}
}
}
Expand All @@ -238,17 +244,24 @@ public void produceGeneratedStaticResources(
@Record(RUNTIME_INIT)
public void runtimeInit(
ConfiguredQuinoaBuildItem configuredQuinoa,
HttpRootPathBuildItem httpRootPath,
NonApplicationRootPathBuildItem nonApplicationRootPath,
QuinoaRecorder recorder,
BuildProducer<RouteBuildItem> routes,
Optional<BuiltResourcesBuildItem> uiResources) throws IOException {
if (configuredQuinoa != null && configuredQuinoa.resolvedConfig().justBuild()) {
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());
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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";

Expand All @@ -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).
*/
Expand Down Expand Up @@ -115,39 +124,94 @@ 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<List<String>> ignoredPathPrefixes();

/**
* Configuration for the external dev server (live coding server)
*/
DevServerConfig devServer();

static List<String> getNormalizedIgnoredPathPrefixes(QuinoaConfig config) {
return config.ignoredPathPrefixes().orElseGet(() -> {
Config allConfig = ConfigProvider.getConfig();
List<String> 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<String> 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<String> 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<String> 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<String> readExternalConfigPath(Config config, String key) {
/**
* <p>
* Normalizes the {@link QuinoaConfig#uiRootPath()} and the returned path always starts with {@code "/"} and ends with
* {@code "/"}.
* <p>
* 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<String> 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<String> 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<String> 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) {
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ public boolean justBuild() {
return delegate.justBuild();
}

@Override
public String uiRootPath() {
return delegate.uiRootPath();
}

@Override
public String uiDir() {
return delegate.uiDir();
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading

0 comments on commit 4eb6dc7

Please sign in to comment.