Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[2.3.x] Add root path for Quinoa Web UI #696

Merged
merged 6 commits into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -194,6 +196,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 @@ -205,8 +209,14 @@ public void runtimeInit(
return;
}
LOG.infof("Quinoa is forwarding unhandled requests to port: %d", devProxy.get().getPort());
final QuinoaHandlerConfig handlerConfig = toHandlerConfig(quinoaConfig, true, httpBuildTimeConfig);
routes.produce(RouteBuildItem.builder().orderedRoute("/*", QUINOA_ROUTE_ORDER)
final QuinoaHandlerConfig handlerConfig = toHandlerConfig(quinoaConfig, true, httpBuildTimeConfig,
nonApplicationRootPath);
String uiRootPath = QuinoaConfig.getNormalizedUiRootPath(quinoaConfig);
// the resolvedUiRootPath is only used for logging
String resolvedUiRootPath = httpRootPath.relativePath(uiRootPath);
recorder.logUiRootPath(resolvedUiRootPath.endsWith("/") ? resolvedUiRootPath : resolvedUiRootPath + "/");
// 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(), devProxy.get().isTls(),
devProxy.get().isTlsAllowInsecure(), devProxy.get().getHost(),
devProxy.get().getPort(),
Expand All @@ -217,7 +227,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))
.build());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
import io.quarkus.resteasy.reactive.server.spi.ResumeOn404BuildItem;
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.runtime.HttpBuildTimeConfig;

Expand Down Expand Up @@ -247,6 +249,8 @@ public void runtimeInit(
HttpBuildTimeConfig httpBuildTimeConfig,
LaunchModeBuildItem launchMode,
Optional<BuiltResourcesBuildItem> uiResources,
HttpRootPathBuildItem httpRootPath,
NonApplicationRootPathBuildItem nonApplicationRootPath,
QuinoaRecorder recorder,
BuildProducer<RouteBuildItem> routes,
BuildProducer<ResumeOn404BuildItem> resumeOn404) throws IOException {
Expand All @@ -261,14 +265,20 @@ public void runtimeInit(
}
final QuinoaHandlerConfig handlerConfig = toHandlerConfig(configuredQuinoa.resolvedConfig(),
launchMode.getLaunchMode() == LaunchMode.DEVELOPMENT,
httpBuildTimeConfig);
httpBuildTimeConfig,
nonApplicationRootPath);
resumeOn404.produce(new ResumeOn404BuildItem());
routes.produce(RouteBuildItem.builder().orderedRoute("/*", QUINOA_ROUTE_ORDER)
String uiRootPath = QuinoaConfig.getNormalizedUiRootPath(configuredQuinoa.resolvedConfig());
// the resolvedUiRootPath is only used for logging
String resolvedUiRootPath = httpRootPath.relativePath(uiRootPath);
recorder.logUiRootPath(resolvedUiRootPath.endsWith("/") ? resolvedUiRootPath : resolvedUiRootPath + "/");
// 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.quinoaHandler(handlerConfig, directory,
uiResources.get().getNames()))
.build());
if (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(handlerConfig))
.build());
}
Expand Down Expand Up @@ -454,4 +464,4 @@ public Path getUIDir() {
}
}

}
}
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.QuinoaHandlerConfig;
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 @@ -121,6 +130,7 @@ public interface QuinoaConfig {

/**
* List of path prefixes to be ignored by Quinoa.
* 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'")
Optional<List<String>> ignoredPathPrefixes();
Expand All @@ -130,25 +140,64 @@ public interface QuinoaConfig {
*/
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.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.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;
});
}

/**
* <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);
}

static QuinoaHandlerConfig toHandlerConfig(QuinoaConfig config, boolean devMode,
final HttpBuildTimeConfig httpBuildTimeConfig) {
final HttpBuildTimeConfig httpBuildTimeConfig, NonApplicationRootPathBuildItem nonApplicationRootPath) {
final Set<String> compressMediaTypes = httpBuildTimeConfig.compressMediaTypes.map(Set::copyOf).orElse(Set.of());
final String indexPage = resolveIndexPage(config, devMode);
return new QuinoaHandlerConfig(getNormalizedIgnoredPathPrefixes(config), indexPage, devMode,
return new QuinoaHandlerConfig(getNormalizedIgnoredPathPrefixes(config, nonApplicationRootPath), indexPage, devMode,
httpBuildTimeConfig.enableCompression, compressMediaTypes, config.devServer().directForwarding());
}

/**
* 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 String resolveIndexPage(QuinoaConfig config, boolean devMode) {
if (!devMode) {
// Make sure we never return the devServer.indexPage() in non-dev mode
Expand All @@ -157,10 +206,25 @@ private static String resolveIndexPage(QuinoaConfig config, boolean devMode) {
return isDevServerMode(config) ? config.devServer().indexPage().orElse(config.indexPage()) : config.indexPage();
}

private static Optional<String> readExternalConfigPath(Config config, String key) {
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 @@ -178,6 +242,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
Expand Up @@ -25,7 +25,7 @@ public class QuinoaAbsoluteUIDirTest {
.anyMatch(s -> s.getMessage().equals("Running Quinoa package manager build command: %s") &&
s.getParameters()[0].equals(systemBinary("npm") + " run build"));
assertThat(l)
.anyMatch(s -> s.getMessage().equals("Quinoa is ignoring paths starting with: /q/"));
.anyMatch(s -> s.getMessage().equals("Quinoa is ignoring paths starting with: /q"));
});

static {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class QuinoaDefaultConfigTest {
.anyMatch(s -> s.getMessage().equals("Running Quinoa package manager build command: %s") &&
s.getParameters()[0].equals(systemBinary("npm") + " run build"));
assertThat(l)
.anyMatch(s -> s.getMessage().equals("Quinoa is ignoring paths starting with: /q/"));
.anyMatch(s -> s.getMessage().equals("Quinoa is ignoring paths starting with: /q"));
});

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -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 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.resteasy-reactive.path", "foo/reactive")
.overrideConfigKey("quarkus.resteasy.path", "foo/classic")
.overrideConfigKey("quarkus.http.non-application-root-path", "bar/non")
.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 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,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 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.resteasy-reactive.path", "/foo/reactive")
.overrideConfigKey("quarkus.resteasy.path", "/foo/classic")
.overrideConfigKey("quarkus.http.non-application-root-path", "/bar/non")
.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 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 @@ -23,7 +23,9 @@ public class QuinoaPathPrefixesRESTConfigTest {
.overrideConfigKey("quarkus.http.non-application-root-path", "/bar/non")
.assertLogRecords(l -> assertThat(l)
.anyMatch(s -> s.getMessage()
.equals("Quinoa is ignoring paths starting with: /foo/classic/, /foo/reactive/, /bar/non/")));
.equals("Quinoa 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