Skip to content

Commit

Permalink
Work to fix path handling and non-application endpoints.
Browse files Browse the repository at this point in the history
Changes include:

- Created `UriNormalizationUtil` for normalizing URI paths
- Added `NonApplicationRootPathBuildItem.routeBuilder()` for constructing non-application endpoint routes
- Modified extensions to utilize the new builder to create routes so any path munging happens inside the builder and not in extensions.
- Added lots of tests
- Added section to extension writing guide for adding non-application endpoints

Co-authored-by: Ken Finnigan <[email protected]>
Co-authored-by: Erin Schnabel <[email protected]>
Co-authored-by: Stuart Douglas <[email protected]>
  • Loading branch information
3 people committed Feb 24, 2021
1 parent e2398cd commit e0f8ec9
Show file tree
Hide file tree
Showing 102 changed files with 2,592 additions and 491 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package io.quarkus.deployment.util;

import java.net.URI;
import java.net.URISyntaxException;

/**
* Common URI path resolution
*/
public class UriNormalizationUtil {
private UriNormalizationUtil() {
}

/**
* Create a URI path from a string. The specified path can not contain
* relative {@literal ..} segments or {@literal %} characters.
* <p>
* Examples:
* <ul>
* <li>{@code toUri("/", true)} will return a URI with path {@literal /}</li>
* <li>{@code toUri("/", false)} will return a URI with an empty path {@literal /}</li>
* <li>{@code toUri("./", true)} will return a URI with path {@literal /}</li>
* <li>{@code toUri("./", false)} will return a URI with an empty path {@literal /}</li>
* <li>{@code toUri("foo/", true)} will return a URI with path {@literal foo/}</li>
* <li>{@code toUri("foo/", false)} will return a URI with an empty path {@literal foo}</li>
* </ul>
*
*
* @param path String to convert into a URI
* @param trailingSlash true if resulting URI must end with a '/'
* @throws IllegalArgumentException if the path contains invalid characters or path segments.
*/
public static URI toURI(String path, boolean trailingSlash) {
try {
// replace inbound // with /
path = path.replaceAll("//", "/");
// remove trailing slash if result shouldn't have one
if (!trailingSlash && path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}

if (path.contains("..") || path.contains("%")) {
throw new IllegalArgumentException("Specified path can not contain '..' or '%'. Path was " + path);
}
URI uri = new URI(path).normalize();
if (uri.getPath().equals("")) {
return trailingSlash ? new URI("/") : new URI("");
} else if (trailingSlash && !path.endsWith("/")) {
uri = new URI(uri.getPath() + "/");
}
return uri;
} catch (URISyntaxException e) {
throw new IllegalArgumentException("Specified path is an invalid URI. Path was " + path, e);
}
}

/**
* Resolve a string path against a URI base. The specified path can not contain
* relative {@literal ..} segments or {@literal %} characters.
*
* Relative paths will be resolved against the specified base URI.
* Absolute paths will be normalized and returned.
* <p>
* Examples:
* <ul>
* <li>{@code normalizeWithBase(new URI("/"), "example", true)}
* will return a URI with path {@literal /example/}</li>
* <li>{@code normalizeWithBase(new URI("/"), "example", false)}
* will return a URI with an empty path {@literal /example}</li>
* <li>{@code normalizeWithBase(new URI("/"), "/example", true)}
* will return a URI with path {@literal /example/}</li>
* <li>{@code normalizeWithBase(new URI("/"), "/example", false)}
* will return a URI with an empty {@literal /example</li>
*
* <li>{@code normalizeWithBase(new URI("/prefix/"), "example", true)}
* will return a URI with path {@literal /prefix/example/}</li>
* <li>{@code normalizeWithBase(new URI("/prefix/"), "example", false)}
* will return a URI with an empty path {@literal /prefix/example}</li>
* <li>{@code normalizeWithBase(new URI("/prefix/"), "/example", true)}
* will return a URI with path {@literal /example/}</li>
* <li>{@code normalizeWithBase(new URI("/prefix/"), "/example", false)}
* will return a URI with an empty path {@literal /example}</li>
*
* <li>{@code normalizeWithBase(new URI("foo/"), "example", true)}
* will return a URI with path {@literal foo/example/}</li>
* <li>{@code normalizeWithBase(new URI("foo/"), "example", false)}
* will return a URI with an empty path {@literal foo/example}</li>
* <li>{@code normalizeWithBase(new URI("foo/"), "/example", true)}
* will return a URI with path {@literal /example/}</li>
* <li>{@code normalizeWithBase(new URI("foo/"), "/example", false)}
* will return a URI with an empty path {@literal /example}</li>
* </ul>
*
* @param base URI to resolve relative paths. Use {@link #toURI(String, boolean)} to construct this parameter.
*
* @param segment Relative or absolute path
* @param trailingSlash true if resulting URI must end with a '/'
* @throws IllegalArgumentException if the path contains invalid characters or path segments.
*/
public static URI normalizeWithBase(URI base, String segment, boolean trailingSlash) {
if (segment == null || segment.trim().isEmpty()) {
return base;
}
URI segmentUri = toURI(segment, trailingSlash);
URI resolvedUri = base.resolve(segmentUri);
return resolvedUri;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package io.quarkus.deployment.util;

import java.net.URI;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class UriNormalizationTest {
@Test
void testToUri() {
Assertions.assertEquals("/", UriNormalizationUtil.toURI("/", true).getPath());
Assertions.assertEquals("", UriNormalizationUtil.toURI("/", false).getPath());

Assertions.assertEquals("/", UriNormalizationUtil.toURI("./", true).getPath());
Assertions.assertEquals("", UriNormalizationUtil.toURI("./", false).getPath());

Assertions.assertEquals("bob/", UriNormalizationUtil.toURI("bob/", true).getPath());
Assertions.assertEquals("bob", UriNormalizationUtil.toURI("bob/", false).getPath());
}

@Test
void testExamples() {
URI root = UriNormalizationUtil.toURI("/", true);
URI prefix = UriNormalizationUtil.toURI("/prefix", true);
URI foo = UriNormalizationUtil.toURI("foo", true);

Assertions.assertEquals("/example/", UriNormalizationUtil.normalizeWithBase(root, "example", true).getPath());
Assertions.assertEquals("/example", UriNormalizationUtil.normalizeWithBase(root, "example", false).getPath());
Assertions.assertEquals("/example/", UriNormalizationUtil.normalizeWithBase(root, "/example", true).getPath());
Assertions.assertEquals("/example", UriNormalizationUtil.normalizeWithBase(root, "/example", false).getPath());

Assertions.assertEquals("/prefix/example/", UriNormalizationUtil.normalizeWithBase(prefix, "example", true).getPath());
Assertions.assertEquals("/prefix/example", UriNormalizationUtil.normalizeWithBase(prefix, "example", false).getPath());
Assertions.assertEquals("/example/", UriNormalizationUtil.normalizeWithBase(prefix, "/example", true).getPath());
Assertions.assertEquals("/example", UriNormalizationUtil.normalizeWithBase(prefix, "/example", false).getPath());

Assertions.assertEquals("foo/example/", UriNormalizationUtil.normalizeWithBase(foo, "example", true).getPath());
Assertions.assertEquals("foo/example", UriNormalizationUtil.normalizeWithBase(foo, "example", false).getPath());
Assertions.assertEquals("/example/", UriNormalizationUtil.normalizeWithBase(foo, "/example", true).getPath());
Assertions.assertEquals("/example", UriNormalizationUtil.normalizeWithBase(foo, "/example", false).getPath());
}

@Test
void testDubiousUriPaths() {
URI root = UriNormalizationUtil.toURI("/", true);

Assertions.assertEquals("/", UriNormalizationUtil.normalizeWithBase(root, "#example", false).getPath());
Assertions.assertEquals("/", UriNormalizationUtil.normalizeWithBase(root, "?example", false).getPath());

Assertions.assertEquals("/example", UriNormalizationUtil.normalizeWithBase(root, "./example", false).getPath());
Assertions.assertEquals("/example", UriNormalizationUtil.normalizeWithBase(root, "//example", false).getPath());

Assertions.assertThrows(IllegalArgumentException.class,
() -> UriNormalizationUtil.normalizeWithBase(root, "/%2fexample", false));
Assertions.assertThrows(IllegalArgumentException.class,
() -> UriNormalizationUtil.normalizeWithBase(root, "junk/../example", false));
Assertions.assertThrows(IllegalArgumentException.class,
() -> UriNormalizationUtil.normalizeWithBase(root, "junk/../../example", false));
Assertions.assertThrows(IllegalArgumentException.class,
() -> UriNormalizationUtil.normalizeWithBase(root, "../example", false));
}
}
3 changes: 2 additions & 1 deletion docs/src/main/asciidoc/openapi-swaggerui.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -467,12 +467,13 @@ You can update the `/swagger-ui` sub path by setting the `quarkus.swagger-ui.pat

[source, properties]
----
quarkus.swagger-ui.path=/my-custom-path
quarkus.swagger-ui.path=my-custom-path
----

[WARNING]
====
The value `/` is not allowed as it blocks the application from serving anything else.
A value prefixed with '/' makes it absolute and not relative.
====

Now, we are ready to run our application:
Expand Down
52 changes: 50 additions & 2 deletions docs/src/main/asciidoc/writing-extensions.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1453,9 +1453,57 @@ As a CDI based runtime, Quarkus extensions often make CDI beans available as par
However, Quarkus DI solution does not support CDI Portable Extensions.
Instead, Quarkus extensions can make use of various link:cdi-reference[Build Time Extension Points].

=== Quarkus DEV Console
=== Quarkus DEV UI

You can make your extension support the link:dev-console[Quarkus DEV Console] for a greater developer experience.
You can make your extension support the link:dev-ui[Quarkus DEV UI] for a greater developer experience.

=== Extension-defined endpoints

Your extension can add additional, non-application endpoints to be served alongside endpoints
for Health, Metrics, OpenAPI, Swagger UI, etc.

Use a `NonApplicationRootPathBuildItem` to define an endpoint:

[source%nowrap,java]
----
@BuildStep
RouteBuildItem myExtensionRoute(NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) {
return nonApplicationRootPathBuildItem.routeBuilder()
.route("custom-endpoint")
.handler(new MyCustomHandler())
.displayOnNotFoundPage()
.build();
}
----

Note that the path above does not start with a '/', indicating it is a relative path. The above
endpoint will be served relative to the configured non-application endpoint root. The non-application
endpoint root is `/q` by default, which means the resulting endpoint will be found at `/q/custom-endpoint`.

Absolute paths are handled differently. If the above called `route("/custom-endpoint")`, the resulting
endpoint will be found at `/custom-endpoint`.

If an extension needs nested non-application endpoints:

[source%nowrap,java]
----
@BuildStep
RouteBuildItem myNestedExtensionRoute(NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) {
return nonApplicationRootPathBuildItem.routeBuilder()
.nestedRoute("custom-endpoint", "deep")
.handler(new MyCustomHandler())
.displayOnNotFoundPage()
.build();
}
----

Given a default non-application endpoint root of `/q`, this will create an endpoint at `/q/custom-endpoint/deep`.

Absolute paths also have an impact on nested endpoints. If the above called `nestedRoute("custom-endpoint", "/deep")`,
the resulting endpoint will be found at `/deep`.

Refer to the link:all-config#quarkus-vertx-http_quarkus.http.non-application-root-path[Quarkus Vertx HTTP configuration reference]
for details on how the non-application root path is configured.

=== Extension Health Check

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ MetricsCapabilityBuildItem metricsCapabilityBuildItem() {
MetricsCapabilityBuildItem metricsCapabilityPrometheusBuildItem(
NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) {
return new MetricsCapabilityBuildItem(MetricsFactory.MICROMETER::equals,
nonApplicationRootPathBuildItem.adjustPath(mConfig.export.prometheus.path));
nonApplicationRootPathBuildItem.resolvePath(mConfig.export.prometheus.path));
}

@BuildStep(onlyIf = MicrometerEnabled.class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import io.quarkus.micrometer.runtime.export.JsonMeterRegistryProvider;
import io.quarkus.micrometer.runtime.export.JsonRecorder;
import io.quarkus.micrometer.runtime.registry.json.JsonMeterRegistry;
import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem;
import io.quarkus.vertx.http.deployment.RouteBuildItem;

public class JsonRegistryProcessor {
Expand All @@ -34,17 +35,21 @@ public void initializeJsonRegistry(MicrometerConfig config,
BuildProducer<MicrometerRegistryProviderBuildItem> registryProviders,
BuildProducer<RouteBuildItem> routes,
BuildProducer<AdditionalBeanBuildItem> additionalBeans,
NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem,
JsonRecorder recorder) {
additionalBeans.produce(AdditionalBeanBuildItem.builder()
.addBeanClass(JsonMeterRegistryProvider.class)
.setUnremovable().build());
registryProviders.produce(new MicrometerRegistryProviderBuildItem(JsonMeterRegistry.class));
routes.produce(new RouteBuildItem.Builder()
.routeFunction(recorder.route(config.export.json.path))

routes.produce(nonApplicationRootPathBuildItem.routeBuilder()
.routeFunction(config.export.json.path, recorder.route())
.handler(recorder.getHandler())
.nonApplicationRoute(true)
.requiresLegacyRedirect()
.build());
log.debug("Initialized a JSON meter registry on path=" + config.export.json.path);

log.debug("Initialized a JSON meter registry on path="
+ nonApplicationRootPathBuildItem.resolvePath(config.export.json.path));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import io.quarkus.micrometer.runtime.config.PrometheusConfigGroup;
import io.quarkus.micrometer.runtime.export.PrometheusMeterRegistryProvider;
import io.quarkus.micrometer.runtime.export.PrometheusRecorder;
import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem;
import io.quarkus.vertx.http.deployment.RouteBuildItem;

/**
Expand Down Expand Up @@ -53,24 +54,25 @@ MicrometerRegistryProviderBuildItem createPrometheusRegistry(
@Record(value = ExecutionTime.STATIC_INIT)
void createPrometheusRoute(BuildProducer<RouteBuildItem> routes,
MicrometerConfig mConfig,
NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem,
PrometheusRecorder recorder) {

PrometheusConfigGroup pConfig = mConfig.export.prometheus;
log.debug("PROMETHEUS CONFIG: " + pConfig);

// Exact match for resources matched to the root path
routes.produce(new RouteBuildItem.Builder()
.routeFunction(recorder.route(pConfig.path))
routes.produce(nonApplicationRootPathBuildItem.routeBuilder()
.routeFunction(pConfig.path, recorder.route())
.handler(recorder.getHandler())
.nonApplicationRoute()
.requiresLegacyRedirect()
.displayOnNotFoundPage("Metrics", pConfig.path)
.build());

// Match paths that begin with the deployment path
String matchPath = pConfig.path + (pConfig.path.endsWith("/") ? "*" : "/*");
routes.produce(new RouteBuildItem.Builder()
.routeFunction(recorder.route(matchPath))
routes.produce(nonApplicationRootPathBuildItem.routeBuilder()
.routeFunction(pConfig.path + (pConfig.path.endsWith("/") ? "*" : "/*"), recorder.route())
.handler(recorder.getHandler())
.nonApplicationRoute()
.requiresLegacyRedirect()
.build());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.quarkus.micrometer.deployment.export;

import java.util.Set;

import javax.inject.Inject;

import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.spec.JavaArchive;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.composite.CompositeMeterRegistry;
import io.quarkus.micrometer.runtime.registry.json.JsonMeterRegistry;
import io.quarkus.test.QuarkusUnitTest;
import io.restassured.RestAssured;

public class JsonRegistryEnabledTest {
@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.withConfigurationResource("test-logging.properties")
.overrideConfigKey("quarkus.http.root-path", "/app")
.overrideConfigKey("quarkus.http.non-application-root-path", "relative")
.overrideConfigKey("quarkus.micrometer.binder-enabled-default", "false")
.overrideConfigKey("quarkus.micrometer.export.json.enabled", "true")
.overrideConfigKey("quarkus.micrometer.registry-enabled-default", "false")
.setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)
.addClass(PrometheusRegistryProcessor.REGISTRY_CLASS));

@Inject
MeterRegistry registry;

@Inject
JsonMeterRegistry jsonMeterRegistry;

@Test
public void testMeterRegistryPresent() {
// Prometheus is enabled (only registry)
Assertions.assertNotNull(registry, "A registry should be configured");
Set<MeterRegistry> subRegistries = ((CompositeMeterRegistry) registry).getRegistries();
JsonMeterRegistry subPromRegistry = (JsonMeterRegistry) subRegistries.iterator().next();
Assertions.assertEquals(JsonMeterRegistry.class, subPromRegistry.getClass(), "Should be JsonMeterRegistry");
Assertions.assertEquals(subPromRegistry, jsonMeterRegistry,
"The only MeterRegistry should be the same bean as the JsonMeterRegistry");
}

@Test
public void metricsEndpoint() {
// RestAssured prepends /app for us
RestAssured.given()
.contentType("application/json")
.get("/relative/metrics")
.then()
.statusCode(200);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ public class JsonConfigGroup implements MicrometerConfig.CapabilityEnabled {

/**
* The path for the JSON metrics endpoint.
* The default value is {@code /metrics}.
* The default value is {@code metrics}.
*/
@ConfigItem(defaultValue = "/metrics")
@ConfigItem(defaultValue = "metrics")
public String path;

/**
Expand Down
Loading

0 comments on commit e0f8ec9

Please sign in to comment.