From 047c5b766f7f7fd619b44f3ddce7c81f4a2a663d Mon Sep 17 00:00:00 2001 From: Alex Martel <13215031+manofthepeace@users.noreply.github.com> Date: Tue, 31 Oct 2023 10:37:55 -0400 Subject: [PATCH 01/20] Add note that endpointdisabled does not work native --- docs/src/main/asciidoc/resteasy-reactive.adoc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/src/main/asciidoc/resteasy-reactive.adoc b/docs/src/main/asciidoc/resteasy-reactive.adoc index b2214898aa36b6..ff681689da94b9 100644 --- a/docs/src/main/asciidoc/resteasy-reactive.adoc +++ b/docs/src/main/asciidoc/resteasy-reactive.adoc @@ -2982,6 +2982,10 @@ public class RuntimeResource { } } ---- +[IMPORTANT] +==== +This feature does not work when using native build. +==== == RESTEasy Reactive client From 6ebbc22aead853b9125f4970f47acf1925cc9049 Mon Sep 17 00:00:00 2001 From: Alexey Loubyansky Date: Fri, 17 Nov 2023 21:32:44 +0100 Subject: [PATCH 02/20] Removed DependencyFlags.REMOVED --- .../deployment/runnerjar/ExcludedArtifactsTest.java | 8 -------- .../bootstrap/model/ApplicationModelBuilder.java | 13 +++++-------- .../quarkus/maven/dependency/DependencyFlags.java | 7 ------- 3 files changed, 5 insertions(+), 23 deletions(-) diff --git a/core/deployment/src/test/java/io/quarkus/deployment/runnerjar/ExcludedArtifactsTest.java b/core/deployment/src/test/java/io/quarkus/deployment/runnerjar/ExcludedArtifactsTest.java index 89935d33638582..b0db1dfb0cbc06 100644 --- a/core/deployment/src/test/java/io/quarkus/deployment/runnerjar/ExcludedArtifactsTest.java +++ b/core/deployment/src/test/java/io/quarkus/deployment/runnerjar/ExcludedArtifactsTest.java @@ -78,13 +78,5 @@ protected void assertAppModel(ApplicationModel model) throws Exception { expected.add(new ArtifactDependency(ArtifactCoords.jar("io.quarkus.bootstrap.test", "dep-g", "1"), DependencyFlags.RUNTIME_CP, DependencyFlags.DEPLOYMENT_CP)); assertEquals(expected, getDependenciesWithFlag(model, DependencyFlags.RUNTIME_CP)); - - expected = new HashSet<>(); - expected.add(new ArtifactDependency(ArtifactCoords.jar("io.quarkus.bootstrap.test", "ext-a-dep", "1"), - DependencyFlags.REMOVED)); - expected.add(new ArtifactDependency(ArtifactCoords.jar("org.banned", "dep-e", "1"), DependencyFlags.REMOVED)); - expected.add(new ArtifactDependency(ArtifactCoords.jar("org.banned.too", "dep-d", "1"), DependencyFlags.REMOVED)); - expected.add(new ArtifactDependency(ArtifactCoords.jar("org.banned", "dep-f", "1"), DependencyFlags.REMOVED)); - assertEquals(expected, getDependenciesWithFlag(model, DependencyFlags.REMOVED)); } } diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/ApplicationModelBuilder.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/ApplicationModelBuilder.java index b4db9c3811fc5c..0dcc5c24da3257 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/ApplicationModelBuilder.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/ApplicationModelBuilder.java @@ -240,31 +240,28 @@ private boolean isExcluded(ArtifactCoords coords) { List buildDependencies() { for (ArtifactKey key : parentFirstArtifacts) { final ResolvedDependencyBuilder d = dependencies.get(key); - if (d != null && !d.isFlagSet(DependencyFlags.REMOVED)) { + if (d != null) { d.setFlags(DependencyFlags.CLASSLOADER_PARENT_FIRST); } } for (ArtifactKey key : runnerParentFirstArtifacts) { final ResolvedDependencyBuilder d = dependencies.get(key); - if (d != null && !d.isFlagSet(DependencyFlags.REMOVED)) { + if (d != null) { d.setFlags(DependencyFlags.CLASSLOADER_RUNNER_PARENT_FIRST); } } for (ArtifactKey key : lesserPriorityArtifacts) { final ResolvedDependencyBuilder d = dependencies.get(key); - if (d != null && !d.isFlagSet(DependencyFlags.REMOVED)) { + if (d != null) { d.setFlags(DependencyFlags.CLASSLOADER_LESSER_PRIORITY); } } final List result = new ArrayList<>(dependencies.size()); for (ResolvedDependencyBuilder db : this.dependencies.values()) { - if (isExcluded(db.getArtifactCoords())) { - db.setFlags(DependencyFlags.REMOVED); - db.clearFlag(DependencyFlags.DEPLOYMENT_CP); - db.clearFlag(DependencyFlags.RUNTIME_CP); + if (!isExcluded(db.getArtifactCoords())) { + result.add(db.build()); } - result.add(db.build()); } return result; } diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/DependencyFlags.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/DependencyFlags.java index dc700789874bf7..8d9c50148784a0 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/DependencyFlags.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/DependencyFlags.java @@ -23,13 +23,6 @@ public interface DependencyFlags { // once the processing of the whole tree has completed. int VISITED = 0b00100000000000; - /** - * Dependencies that were removed from the application model - * following {@code removed-artifacts} - * configuration properties collected from extension metadata. - */ - int REMOVED = 0b01000000000000; - /* @formatter:on */ } From c48339d0f9d84bd0770ad942a20448ed543bf388 Mon Sep 17 00:00:00 2001 From: marko-bekhta Date: Mon, 20 Nov 2023 18:55:12 +0100 Subject: [PATCH 03/20] Add classes from additional JPA model build items to pre-generate proxies --- .../hibernate/orm/deployment/HibernateOrmProcessor.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java index 8a45d4ceca3c57..7d0101630b26a5 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java @@ -416,6 +416,7 @@ public BytecodeRecorderConstantDefinitionBuildItem pregenProxies( JpaModelIndexBuildItem indexBuildItem, TransformedClassesBuildItem transformedClassesBuildItem, List persistenceUnitDescriptorBuildItems, + List additionalJpaModelBuildItems, BuildProducer generatedClassBuildItemBuildProducer, LiveReloadBuildItem liveReloadBuildItem) { Set managedClassAndPackageNames = new HashSet<>(jpaModel.getEntityClassNames()); @@ -426,6 +427,11 @@ public BytecodeRecorderConstantDefinitionBuildItem pregenProxies( // is used for packages too, and it relies (indirectly) on getManagedClassNames(). managedClassAndPackageNames.addAll(pud.getManagedClassNames()); } + + for (AdditionalJpaModelBuildItem additionalJpaModelBuildItem : additionalJpaModelBuildItems) { + managedClassAndPackageNames.add(additionalJpaModelBuildItem.getClassName()); + } + PreGeneratedProxies proxyDefinitions = generatedProxies(managedClassAndPackageNames, indexBuildItem.getIndex(), transformedClassesBuildItem, generatedClassBuildItemBuildProducer, liveReloadBuildItem); From ecc147940dd9d8ecb65d497fb0e89874414c2d1d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 22:35:56 +0000 Subject: [PATCH 04/20] Bump org.eclipse.microprofile.telemetry.tracing:microprofile-telemetry-tracing-tck Bumps org.eclipse.microprofile.telemetry.tracing:microprofile-telemetry-tracing-tck from 1.0 to 1.1. --- updated-dependencies: - dependency-name: org.eclipse.microprofile.telemetry.tracing:microprofile-telemetry-tracing-tck dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- tcks/microprofile-opentelemetry/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tcks/microprofile-opentelemetry/pom.xml b/tcks/microprofile-opentelemetry/pom.xml index 085f512f14d5e9..bdcab791b5eb64 100644 --- a/tcks/microprofile-opentelemetry/pom.xml +++ b/tcks/microprofile-opentelemetry/pom.xml @@ -13,7 +13,7 @@ Quarkus - TCK - MicroProfile OpenTelemetry - 1.0 + 1.1 From 6b66359920880494aff9be13945735ca3ab72b9f Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Mon, 20 Nov 2023 16:25:02 +0200 Subject: [PATCH 05/20] Allow REST Client to return the entire SSE event This can be useful when the id or the name of the event contain useful metadata Closes: #37107 --- .../reactive/jackson/test/MultiSseTest.java | 94 +++++++++++++++++++ .../resteasy/reactive/client/SseEvent.java | 15 +++ .../reactive/client/impl/MultiInvoker.java | 44 ++++++++- 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/SseEvent.java diff --git a/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/test/java/io/quarkus/rest/client/reactive/jackson/test/MultiSseTest.java b/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/test/java/io/quarkus/rest/client/reactive/jackson/test/MultiSseTest.java index aa715e04fb9482..780bb6b9316942 100644 --- a/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/test/java/io/quarkus/rest/client/reactive/jackson/test/MultiSseTest.java +++ b/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/test/java/io/quarkus/rest/client/reactive/jackson/test/MultiSseTest.java @@ -8,15 +8,21 @@ import java.util.Objects; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.sse.OutboundSseEvent; +import jakarta.ws.rs.sse.Sse; +import jakarta.ws.rs.sse.SseEventSink; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; import org.jboss.resteasy.reactive.RestStreamElementType; +import org.jboss.resteasy.reactive.client.SseEvent; import org.jboss.resteasy.reactive.server.jackson.JacksonBasicMessageBodyReader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -112,6 +118,63 @@ void shouldRestStreamElementTypeOverwriteProducesAtClassLevel() { .containsExactly(new Dto("foo", "bar"), new Dto("chocolate", "bar"))); } + @Test + void shouldBeAbleReadEntireEvent() { + var resultList = new CopyOnWriteArrayList<>(); + createClient() + .event() + .subscribe().with(new Consumer<>() { + @Override + public void accept(SseEvent event) { + resultList.add(new EventContainer(event.id(), event.name(), event.data())); + } + }); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> assertThat(resultList).containsExactly( + new EventContainer("id0", "name0", new Dto("name0", "0")), + new EventContainer("id1", "name1", new Dto("name1", "1")))); + } + + static class EventContainer { + final String id; + final String name; + final Dto dto; + + EventContainer(String id, String name, Dto dto) { + this.id = id; + this.name = name; + this.dto = dto; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + EventContainer that = (EventContainer) o; + return Objects.equals(id, that.id) && Objects.equals(name, that.name) + && Objects.equals(dto, that.dto); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, dto); + } + + @Override + public String toString() { + return "EventContainer{" + + "id='" + id + '\'' + + ", name='" + name + '\'' + + ", dto=" + dto + + '}'; + } + } + private SseClient createClient() { return QuarkusRestClientBuilder.newBuilder() .baseUri(uri) @@ -144,6 +207,11 @@ public interface SseClient { @Produces(MediaType.SERVER_SENT_EVENTS) @Path("/with-entity-json") Multi> postAndReadAsMap(String entity); + + @GET + @Path("/event") + @Produces(MediaType.SERVER_SENT_EVENTS) + Multi> event(); } @Path("/sse") @@ -175,6 +243,24 @@ public Multi post(String entity) { public Multi postAndReadAsMap(String entity) { return Multi.createBy().repeating().supplier(() -> new Dto("foo", entity)).atMost(3); } + + @GET + @Path("/event") + @Produces(MediaType.SERVER_SENT_EVENTS) + public void event(@Context SseEventSink sink, @Context Sse sse) { + // send a stream of few events + try (sink) { + for (int i = 0; i < 2; i++) { + final OutboundSseEvent.Builder builder = sse.newEventBuilder(); + builder.id("id" + i) + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(Dto.class, new Dto("name" + i, String.valueOf(i))) + .name("name" + i); + + sink.send(builder.build()); + } + } + } } @Path("/sse-rest-stream-element-type") @@ -226,5 +312,13 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(name, value); } + + @Override + public String toString() { + return "Dto{" + + "name='" + name + '\'' + + ", value='" + value + '\'' + + '}'; + } } } diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/SseEvent.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/SseEvent.java new file mode 100644 index 00000000000000..a6978b93d2dc72 --- /dev/null +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/SseEvent.java @@ -0,0 +1,15 @@ +package org.jboss.resteasy.reactive.client; + +/** + * Represents the entire SSE response from the server + */ +public interface SseEvent { + + String id(); + + String name(); + + String comment(); + + T data(); +} diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/MultiInvoker.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/MultiInvoker.java index fe6a93492c42f4..e483baa0ce3570 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/MultiInvoker.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/MultiInvoker.java @@ -2,6 +2,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; +import java.lang.reflect.ParameterizedType; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -10,6 +11,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import org.jboss.resteasy.reactive.client.SseEvent; import org.jboss.resteasy.reactive.common.jaxrs.ResponseImpl; import org.jboss.resteasy.reactive.common.util.RestMediaType; @@ -151,10 +153,17 @@ private boolean isNewlineDelimited(ResponseImpl response) { RestMediaType.APPLICATION_NDJSON_TYPE.isCompatible(response.getMediaType()); } + @SuppressWarnings({ "unchecked", "rawtypes" }) private void registerForSse(MultiRequest multiRequest, GenericType responseType, Response response, HttpClientResponse vertxResponse, String defaultContentType) { + + boolean returnSseEvent = SseEvent.class.equals(responseType.getRawType()); + GenericType responseTypeFirstParam = responseType.getType() instanceof ParameterizedType + ? new GenericType(((ParameterizedType) responseType.getType()).getActualTypeArguments()[0]) + : null; + // honestly, isn't reconnect contradictory with completion? // FIXME: Reconnect settings? // For now we don't want multi to reconnect @@ -165,10 +174,39 @@ private void registerForSse(MultiRequest multiRequest, sseSource.register(event -> { // DO NOT pass the response mime type because it's SSE: let the event pick between the X-SSE-Content-Type header or // the content-type SSE field - R item = event.readData(responseType); - if (item != null) { // we don't emit null because it breaks Multi (by design) - multiRequest.emit(item); + if (returnSseEvent) { + multiRequest.emit((R) new SseEvent() { + @Override + public String id() { + return event.getId(); + } + + @Override + public String name() { + return event.getName(); + } + + @Override + public String comment() { + return event.getComment(); + } + + @Override + public Object data() { + if (responseTypeFirstParam != null) { + return event.readData(responseTypeFirstParam); + } else { + return event.readData(); // TODO: is this correct? + } + } + }); + } else { + R item = event.readData(responseType); + if (item != null) { // we don't emit null because it breaks Multi (by design) + multiRequest.emit(item); + } } + }, multiRequest::fail, multiRequest::complete); // watch for user cancelling sseSource.registerAfterRequest(vertxResponse); From 2ff97152d6c07d8b3b4a7effaa98e37f6c2e79f0 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Mon, 20 Nov 2023 15:22:35 +0100 Subject: [PATCH 06/20] Qute: dev mode - no-restart-templates - fix javadoc in QuteDevModeConfig - improve NoRestartTemplatesDevModeTest --- .../deployment/devmode/NoRestartRoute.java | 13 +++++++-- .../NoRestartTemplatesDevModeTest.java | 28 ++++++++++++++----- .../qute/runtime/QuteDevModeConfig.java | 4 +-- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/devmode/NoRestartRoute.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/devmode/NoRestartRoute.java index 454868cd93d710..43f088655cfda7 100644 --- a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/devmode/NoRestartRoute.java +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/devmode/NoRestartRoute.java @@ -6,6 +6,7 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; +import io.quarkus.qute.Location; import io.quarkus.qute.Template; import io.quarkus.vertx.web.Route; import io.vertx.ext.web.RoutingContext; @@ -15,12 +16,20 @@ public class NoRestartRoute { private String id; - @Inject + @Location("foo/norestart") Template norestart; + @Inject + Template bar; + @Route(path = "norestart") public void test(RoutingContext ctx) { - ctx.end(norestart.data("foo", id).render()); + ctx.end(norestart.data("id", id).render()); + } + + @Route(path = "bar") + public void testBar(RoutingContext ctx) { + ctx.end(bar.data("id", id).render()); } @PostConstruct diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/devmode/NoRestartTemplatesDevModeTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/devmode/NoRestartTemplatesDevModeTest.java index c7d9dca8e67172..5636fa2b777827 100644 --- a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/devmode/NoRestartTemplatesDevModeTest.java +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/devmode/NoRestartTemplatesDevModeTest.java @@ -18,10 +18,13 @@ public class NoRestartTemplatesDevModeTest { .withApplicationRoot(root -> root .addClass(NoRestartRoute.class) .addAsResource(new StringAsset( - "Hello {foo}!"), - "templates/norestart.html") + "Hello {id}!"), + "templates/foo/norestart.html") .addAsResource(new StringAsset( - "quarkus.qute.dev-mode.no-restart-templates=templates/norestart.html"), + "Hi {id}!"), + "templates/bar.html") + .addAsResource(new StringAsset( + "quarkus.qute.dev-mode.no-restart-templates=templates/.+"), "application.properties")); @Test @@ -29,14 +32,25 @@ public void testNoRestartTemplates() { Response resp = given().get("norestart"); resp.then() .statusCode(200); - String val = resp.getBody().asString(); - assertTrue(val.startsWith("Hello ")); + String val1 = resp.getBody().asString(); + assertTrue(val1.startsWith("Hello ")); + + resp = given().get("bar"); + resp.then() + .statusCode(200); + String val2 = resp.getBody().asString(); + assertTrue(val2.startsWith("Hi ")); - config.modifyResourceFile("templates/norestart.html", t -> t.concat("!!")); + config.modifyResourceFile("templates/foo/norestart.html", t -> t.concat("!!")); + config.modifyResourceFile("templates/bar.html", t -> t.concat("!!")); resp = given().get("norestart"); resp.then().statusCode(200); - assertEquals(val + "!!", resp.getBody().asString()); + assertEquals(val1 + "!!", resp.getBody().asString()); + + resp = given().get("bar"); + resp.then().statusCode(200); + assertEquals(val2 + "!!", resp.getBody().asString()); } } diff --git a/extensions/qute/runtime/src/main/java/io/quarkus/qute/runtime/QuteDevModeConfig.java b/extensions/qute/runtime/src/main/java/io/quarkus/qute/runtime/QuteDevModeConfig.java index be0787fc88113f..843a08b3eadcfe 100644 --- a/extensions/qute/runtime/src/main/java/io/quarkus/qute/runtime/QuteDevModeConfig.java +++ b/extensions/qute/runtime/src/main/java/io/quarkus/qute/runtime/QuteDevModeConfig.java @@ -15,8 +15,8 @@ public class QuteDevModeConfig { * This regular expression can be used to specify the templates for which the application is not restarted. * I.e. the templates are reloaded and only runtime validations are performed. *

- * The matched input is the template path relative from the {@code templates} directory and the - * {@code /} is used as a path separator. For example, {@code templates/foo.html}. + * The matched input is the template path that starts with a template root, and the {@code /} is used as a path separator. + * For example, {@code templates/foo.html}. */ @ConfigItem public Optional noRestartTemplates; From 808fba793396abe0a808c88e60a326a184ec6514 Mon Sep 17 00:00:00 2001 From: Andy Damevin Date: Tue, 21 Nov 2023 09:48:58 +0100 Subject: [PATCH 07/20] Allow to retrieve minimum and recommended Java versions from catalog metadata --- .../platform/catalog/processor/CatalogProcessor.java | 8 ++++++++ .../platform/catalog/processor/MetadataValue.java | 2 +- .../src/main/resources/fake-catalog.json | 2 ++ .../io/quarkus/registry/catalog/ExtensionCatalog.java | 3 +++ .../quarkus/platform/catalog/CatalogProcessorTest.java | 9 +++++++++ .../devtools/src/test/resources/platform-metadata.json | 4 +++- 6 files changed, 26 insertions(+), 2 deletions(-) diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/platform/catalog/processor/CatalogProcessor.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/platform/catalog/processor/CatalogProcessor.java index 8fbd392efdce0d..ae0037e90f61b8 100644 --- a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/platform/catalog/processor/CatalogProcessor.java +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/platform/catalog/processor/CatalogProcessor.java @@ -85,4 +85,12 @@ public List getProcessedCategoriesInOrder() { public static MetadataValue getMetadataValue(ExtensionCatalog catalog, String path) { return MetadataValue.get(catalog.getMetadata(), path); } + + public static String getMinimumJavaVersion(ExtensionCatalog catalog) { + return getMetadataValue(catalog, ExtensionCatalog.MD_MINIMUM_JAVA_VERSION).asString(); + } + + public static String getRecommendedJavaVersion(ExtensionCatalog catalog) { + return getMetadataValue(catalog, ExtensionCatalog.MD_RECOMMENDED_JAVA_VERSION).asString(); + } } diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/platform/catalog/processor/MetadataValue.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/platform/catalog/processor/MetadataValue.java index 11ffee9d9a9dfd..13a71dabb43215 100644 --- a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/platform/catalog/processor/MetadataValue.java +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/platform/catalog/processor/MetadataValue.java @@ -5,7 +5,7 @@ import java.util.Locale; import java.util.Map; -final class MetadataValue { +public final class MetadataValue { private static final MetadataValue EMPTY_METADATA_VALUE = new MetadataValue(null); private final Object val; diff --git a/independent-projects/tools/devtools-testing/src/main/resources/fake-catalog.json b/independent-projects/tools/devtools-testing/src/main/resources/fake-catalog.json index c8e7e561183686..3b970eaec4e5a5 100644 --- a/independent-projects/tools/devtools-testing/src/main/resources/fake-catalog.json +++ b/independent-projects/tools/devtools-testing/src/main/resources/fake-catalog.json @@ -386,6 +386,8 @@ "gradle-plugin-id": "io.quarkus", "gradle-plugin-version": "999-FAKE", "supported-maven-versions": "[3.6.2,)", + "minimum-java-version": "11", + "recommended-java-version": "17", "proposed-maven-version": "3.9.5", "maven-wrapper-version": "3.2.0", "gradle-wrapper-version": "8.4" diff --git a/independent-projects/tools/registry-client/src/main/java/io/quarkus/registry/catalog/ExtensionCatalog.java b/independent-projects/tools/registry-client/src/main/java/io/quarkus/registry/catalog/ExtensionCatalog.java index 2bcdb7eef419a9..91f7d027e61cb0 100644 --- a/independent-projects/tools/registry-client/src/main/java/io/quarkus/registry/catalog/ExtensionCatalog.java +++ b/independent-projects/tools/registry-client/src/main/java/io/quarkus/registry/catalog/ExtensionCatalog.java @@ -11,6 +11,9 @@ public interface ExtensionCatalog extends ExtensionOrigin { + String MD_MINIMUM_JAVA_VERSION = "project.properties.minimum-java-version"; + String MD_RECOMMENDED_JAVA_VERSION = "project.properties.recommended-java-version"; + /** * All the origins this catalog is derived from. * diff --git a/integration-tests/devtools/src/test/java/io/quarkus/platform/catalog/CatalogProcessorTest.java b/integration-tests/devtools/src/test/java/io/quarkus/platform/catalog/CatalogProcessorTest.java index 0b7f884660c55d..83dbe859c7c00a 100644 --- a/integration-tests/devtools/src/test/java/io/quarkus/platform/catalog/CatalogProcessorTest.java +++ b/integration-tests/devtools/src/test/java/io/quarkus/platform/catalog/CatalogProcessorTest.java @@ -1,7 +1,9 @@ package io.quarkus.platform.catalog; import static io.quarkus.devtools.testing.FakeExtensionCatalog.newFakeExtensionCatalog; +import static io.quarkus.platform.catalog.processor.CatalogProcessor.getMinimumJavaVersion; import static io.quarkus.platform.catalog.processor.CatalogProcessor.getProcessedCategoriesInOrder; +import static io.quarkus.platform.catalog.processor.CatalogProcessor.getRecommendedJavaVersion; import static org.assertj.core.api.Assertions.assertThat; import java.util.Objects; @@ -27,6 +29,13 @@ void testCategoryOrder() { .startsWith("web", "core", "reactive", "serialization", "compatibility", "alt-languages", "uncategorized"); } + @Test + void testJavaVersions() { + final ExtensionCatalog catalog = newFakeExtensionCatalog(); + assertThat(getMinimumJavaVersion(catalog)).isEqualTo("11"); + assertThat(getRecommendedJavaVersion(catalog)).isEqualTo("17"); + } + @Test void testExtensionsOrder() { final ExtensionCatalog catalog = newFakeExtensionCatalog(); diff --git a/integration-tests/devtools/src/test/resources/platform-metadata.json b/integration-tests/devtools/src/test/resources/platform-metadata.json index 59160da30fb5b1..b4b073f5b6adf5 100644 --- a/integration-tests/devtools/src/test/resources/platform-metadata.json +++ b/integration-tests/devtools/src/test/resources/platform-metadata.json @@ -43,7 +43,9 @@ "supported-maven-versions": "${supported-maven-versions}", "proposed-maven-version": "${proposed-maven-version}", "maven-wrapper-version": "${maven-wrapper.version}", - "gradle-wrapper-version": "${gradle-wrapper.version}" + "gradle-wrapper-version": "${gradle-wrapper.version}", + "minimum-java-version": "${minimum-java-version}", + "recommended-java-version": "${recommended-java-version}" } }, "codestarts-artifacts": [ From 542e2b92cba4e8af11e799736fa9da0ace015f92 Mon Sep 17 00:00:00 2001 From: Andy Damevin Date: Tue, 21 Nov 2023 10:03:50 +0100 Subject: [PATCH 08/20] Add method to retrieve the list of compatible java versions --- .../java/io/quarkus/devtools/project/JavaVersion.java | 10 ++++++++++ .../io/quarkus/devtools/project/JavaVersionTest.java | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/project/JavaVersion.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/project/JavaVersion.java index 6dd09ec9c887b0..ffa49e92acafbb 100644 --- a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/project/JavaVersion.java +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/project/JavaVersion.java @@ -6,6 +6,7 @@ import java.util.TreeSet; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; public final class JavaVersion { @@ -71,6 +72,15 @@ public static int determineBestJavaLtsVersion() { return determineBestJavaLtsVersion(Runtime.version().feature()); } + public static SortedSet getCompatibleLTSVersions(JavaVersion minimumJavaVersion) { + if (minimumJavaVersion.isEmpty()) { + return JAVA_VERSIONS_LTS; + } + return JAVA_VERSIONS_LTS.stream() + .filter(v -> v >= minimumJavaVersion.getAsInt()) + .collect(Collectors.toCollection(TreeSet::new)); + } + public static int determineBestJavaLtsVersion(int runtimeVersion) { int bestLtsVersion = DEFAULT_JAVA_VERSION; for (int ltsVersion : JAVA_VERSIONS_LTS) { diff --git a/independent-projects/tools/devtools-common/src/test/java/io/quarkus/devtools/project/JavaVersionTest.java b/independent-projects/tools/devtools-common/src/test/java/io/quarkus/devtools/project/JavaVersionTest.java index 285a582e81b2c9..3e0d82dd752414 100644 --- a/independent-projects/tools/devtools-common/src/test/java/io/quarkus/devtools/project/JavaVersionTest.java +++ b/independent-projects/tools/devtools-common/src/test/java/io/quarkus/devtools/project/JavaVersionTest.java @@ -1,10 +1,13 @@ package io.quarkus.devtools.project; import static io.quarkus.devtools.project.JavaVersion.DETECT_JAVA_RUNTIME_VERSION; +import static io.quarkus.devtools.project.JavaVersion.JAVA_VERSIONS_LTS; import static io.quarkus.devtools.project.JavaVersion.computeJavaVersion; import static io.quarkus.devtools.project.JavaVersion.determineBestJavaLtsVersion; +import static io.quarkus.devtools.project.JavaVersion.getCompatibleLTSVersions; import static io.quarkus.devtools.project.SourceType.JAVA; import static io.quarkus.devtools.project.SourceType.KOTLIN; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; @@ -31,6 +34,14 @@ public void givenJavaVersion18ShouldReturn17() { assertEquals("17", computeJavaVersion(JAVA, "18")); } + @Test + void shouldProperlyUseMinJavaVersion() { + assertThat(getCompatibleLTSVersions(new JavaVersion("11"))).isEqualTo(JAVA_VERSIONS_LTS); + assertThat(getCompatibleLTSVersions(new JavaVersion("17"))).containsExactly(17, 21); + assertThat(getCompatibleLTSVersions(new JavaVersion("100"))).isEmpty(); + assertThat(getCompatibleLTSVersions(JavaVersion.NA)).isEqualTo(JAVA_VERSIONS_LTS); + } + @Test public void givenAutoDetectShouldReturnAppropriateVersion() { final String bestJavaLtsVersion = String.valueOf(determineBestJavaLtsVersion(Runtime.version().feature())); From c9d1eeae65a34632ee993b19ab48755e16cdf596 Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Tue, 21 Nov 2023 10:56:35 +0200 Subject: [PATCH 09/20] Allow SSE events to be filtered out from REST Client --- .../reactive/jackson/test/MultiSseTest.java | 85 +++++++++++++++++++ .../client/reactive/deployment/DotNames.java | 3 + .../RestClientReactiveProcessor.java | 37 ++++++++ .../resteasy/reactive/client/SseEvent.java | 27 ++++++ .../reactive/client/SseEventFilter.java | 22 +++++ .../reactive/client/impl/MultiInvoker.java | 69 +++++++++++++-- 6 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/SseEventFilter.java diff --git a/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/test/java/io/quarkus/rest/client/reactive/jackson/test/MultiSseTest.java b/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/test/java/io/quarkus/rest/client/reactive/jackson/test/MultiSseTest.java index 780bb6b9316942..629b881a93bec0 100644 --- a/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/test/java/io/quarkus/rest/client/reactive/jackson/test/MultiSseTest.java +++ b/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/test/java/io/quarkus/rest/client/reactive/jackson/test/MultiSseTest.java @@ -9,6 +9,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import java.util.function.Predicate; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; @@ -23,6 +24,7 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; import org.jboss.resteasy.reactive.RestStreamElementType; import org.jboss.resteasy.reactive.client.SseEvent; +import org.jboss.resteasy.reactive.client.SseEventFilter; import org.jboss.resteasy.reactive.server.jackson.JacksonBasicMessageBodyReader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -136,6 +138,25 @@ public void accept(SseEvent event) { new EventContainer("id1", "name1", new Dto("name1", "1")))); } + @Test + void shouldBeAbleReadEntireEventWhileAlsoBeingAbleToFilterEvents() { + var resultList = new CopyOnWriteArrayList<>(); + createClient() + .eventWithFilter() + .subscribe().with(new Consumer<>() { + @Override + public void accept(SseEvent event) { + resultList.add(new EventContainer(event.id(), event.name(), event.data())); + } + }); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> assertThat(resultList).containsExactly( + new EventContainer("id", "n0", new Dto("name0", "0")), + new EventContainer("id", "n1", new Dto("name1", "1")), + new EventContainer("id", "n2", new Dto("name2", "2")))); + } + static class EventContainer { final String id; final String name; @@ -212,6 +233,26 @@ public interface SseClient { @Path("/event") @Produces(MediaType.SERVER_SENT_EVENTS) Multi> event(); + + @GET + @Path("/event-with-filter") + @Produces(MediaType.SERVER_SENT_EVENTS) + @SseEventFilter(CustomFilter.class) + Multi> eventWithFilter(); + } + + public static class CustomFilter implements Predicate> { + + @Override + public boolean test(SseEvent event) { + if ("heartbeat".equals(event.id())) { + return false; + } + if ("END".equals(event.data())) { + return false; + } + return true; + } } @Path("/sse") @@ -261,6 +302,50 @@ public void event(@Context SseEventSink sink, @Context Sse sse) { } } } + + @GET + @Path("/event-with-filter") + @Produces(MediaType.SERVER_SENT_EVENTS) + public void eventWithFilter(@Context SseEventSink sink, @Context Sse sse) { + try (sink) { + sink.send(sse.newEventBuilder() + .id("id") + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(Dto.class, new Dto("name0", "0")) + .name("n0") + .build()); + + sink.send(sse.newEventBuilder() + .id("heartbeat") + .comment("heartbeat") + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .build()); + + sink.send(sse.newEventBuilder() + .id("id") + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(Dto.class, new Dto("name1", "1")) + .name("n1") + .build()); + + sink.send(sse.newEventBuilder() + .id("heartbeat") + .comment("heartbeat") + .build()); + + sink.send(sse.newEventBuilder() + .id("id") + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(Dto.class, new Dto("name2", "2")) + .name("n2") + .build()); + + sink.send(sse.newEventBuilder() + .id("end") + .data("END") + .build()); + } + } } @Path("/sse-rest-stream-element-type") diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/DotNames.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/DotNames.java index add3e44795d658..f635e470595a4e 100644 --- a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/DotNames.java +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/DotNames.java @@ -12,6 +12,7 @@ import org.eclipse.microprofile.rest.client.annotation.RegisterProviders; import org.eclipse.microprofile.rest.client.ext.ResponseExceptionMapper; import org.jboss.jandex.DotName; +import org.jboss.resteasy.reactive.client.SseEventFilter; import io.quarkus.rest.client.reactive.ClientExceptionMapper; import io.quarkus.rest.client.reactive.ClientFormParam; @@ -41,6 +42,8 @@ public class DotNames { static final DotName METHOD = DotName.createSimple(Method.class.getName()); + public static final DotName SSE_EVENT_FILTER = DotName.createSimple(SseEventFilter.class); + private DotNames() { } } diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java index 49ee3402e3daff..22a4b76f9b69ea 100644 --- a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java @@ -64,6 +64,7 @@ import org.jboss.resteasy.reactive.common.util.QuarkusMultivaluedHashMap; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.BeanArchiveIndexBuildItem; import io.quarkus.arc.deployment.CustomScopeAnnotationsBuildItem; import io.quarkus.arc.deployment.GeneratedBeanBuildItem; import io.quarkus.arc.deployment.GeneratedBeanGizmoAdaptor; @@ -371,6 +372,42 @@ void registerCompressionInterceptors(BuildProducer ref } } + @BuildStep + void handleSseEventFilter(BuildProducer reflectiveClasses, + BeanArchiveIndexBuildItem beanArchiveIndexBuildItem) { + var index = beanArchiveIndexBuildItem.getIndex(); + Collection instances = index.getAnnotations(DotNames.SSE_EVENT_FILTER); + if (instances.isEmpty()) { + return; + } + + List filterClassNames = new ArrayList<>(instances.size()); + for (AnnotationInstance instance : instances) { + if (instance.target().kind() != AnnotationTarget.Kind.METHOD) { + continue; + } + if (instance.value() == null) { + continue; // can't happen + } + Type filterType = instance.value().asClass(); + DotName filterClassName = filterType.name(); + ClassInfo filterClassInfo = index.getClassByName(filterClassName.toString()); + if (filterClassInfo == null) { + log.warn("Unable to find class '" + filterType.name() + "' in index"); + } else if (!filterClassInfo.hasNoArgsConstructor()) { + throw new RestClientDefinitionException( + "Classes used in @SseEventFilter must have a no-args constructor. Offending class is '" + + filterClassName + "'"); + } else { + filterClassNames.add(filterClassName.toString()); + } + } + reflectiveClasses.produce(ReflectiveClassBuildItem + .builder(filterClassNames.toArray(new String[0])) + .constructors(true) + .build()); + } + @BuildStep @Record(ExecutionTime.STATIC_INIT) void addRestClientBeans(Capabilities capabilities, diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/SseEvent.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/SseEvent.java index a6978b93d2dc72..bcbee51c809dc4 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/SseEvent.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/SseEvent.java @@ -5,11 +5,38 @@ */ public interface SseEvent { + /** + * Get event identifier. + *

+ * Contains value of SSE {@code "id"} field. This field is optional. Method may return {@code null}, if the event + * identifier is not specified. + * + * @return event id. + */ String id(); + /** + * Get event name. + *

+ * Contains value of SSE {@code "event"} field. This field is optional. Method may return {@code null}, if the event + * name is not specified. + * + * @return event name, or {@code null} if not set. + */ String name(); + /** + * Get a comment string that accompanies the event. + *

+ * Contains value of the comment associated with SSE event. This field is optional. Method may return {@code null}, if + * the event comment is not specified. + * + * @return comment associated with the event. + */ String comment(); + /** + * Get event data. + */ T data(); } diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/SseEventFilter.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/SseEventFilter.java new file mode 100644 index 00000000000000..d9419dca5dfdb2 --- /dev/null +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/SseEventFilter.java @@ -0,0 +1,22 @@ +package org.jboss.resteasy.reactive.client; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.function.Predicate; + +/** + * Used when not all SSE events streamed from the server should be included in the event stream returned by the client. + *

+ * IMPORTANT: implementations MUST contain a no-args constructor + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface SseEventFilter { + + /** + * Predicate which decides whether an event should be included in the event stream returned by the client. + */ + Class>> value(); +} diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/MultiInvoker.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/MultiInvoker.java index e483baa0ce3570..4459e66000227a 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/MultiInvoker.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/MultiInvoker.java @@ -2,16 +2,19 @@ import java.io.ByteArrayInputStream; import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.GenericType; import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; import org.jboss.resteasy.reactive.client.SseEvent; +import org.jboss.resteasy.reactive.client.SseEventFilter; import org.jboss.resteasy.reactive.common.jaxrs.ResponseImpl; import org.jboss.resteasy.reactive.common.util.RestMediaType; @@ -45,8 +48,8 @@ public Multi get(GenericType responseType) { /** * We need this class to work around a bug in Mutiny where we can register our cancel listener - * after the subscription is cancelled and we never get notified - * See https://github.com/smallrye/smallrye-mutiny/issues/417 + * after the subscription is cancelled, and we never get notified + * See ... */ static class MultiRequest { @@ -127,9 +130,11 @@ public Multi method(String name, Entity entity, GenericType respons if (!emitter.isCancelled()) { if (response.getStatus() == 200 && MediaType.SERVER_SENT_EVENTS_TYPE.isCompatible(response.getMediaType())) { - registerForSse(multiRequest, responseType, response, vertxResponse, + registerForSse( + multiRequest, responseType, vertxResponse, (String) restClientRequestContext.getProperties() - .get(RestClientRequestContext.DEFAULT_CONTENT_TYPE_PROP)); + .get(RestClientRequestContext.DEFAULT_CONTENT_TYPE_PROP), + restClientRequestContext.getInvokedMethod()); } else if (response.getStatus() == 200 && RestMediaType.APPLICATION_STREAM_JSON_TYPE.isCompatible(response.getMediaType())) { registerForJsonStream(multiRequest, restClientRequestContext, responseType, response, @@ -156,14 +161,16 @@ private boolean isNewlineDelimited(ResponseImpl response) { @SuppressWarnings({ "unchecked", "rawtypes" }) private void registerForSse(MultiRequest multiRequest, GenericType responseType, - Response response, - HttpClientResponse vertxResponse, String defaultContentType) { + HttpClientResponse vertxResponse, String defaultContentType, + Method invokedMethod) { boolean returnSseEvent = SseEvent.class.equals(responseType.getRawType()); GenericType responseTypeFirstParam = responseType.getType() instanceof ParameterizedType ? new GenericType(((ParameterizedType) responseType.getType()).getActualTypeArguments()[0]) : null; + Predicate> eventPredicate = createEventPredicate(invokedMethod); + // honestly, isn't reconnect contradictory with completion? // FIXME: Reconnect settings? // For now we don't want multi to reconnect @@ -172,8 +179,39 @@ private void registerForSse(MultiRequest multiRequest, multiRequest.onCancel(sseSource::close); sseSource.register(event -> { + + // TODO: we might want to cut down on the allocations here... + + if (eventPredicate != null) { + boolean keep = eventPredicate.test(new SseEvent<>() { + @Override + public String id() { + return event.getId(); + } + + @Override + public String name() { + return event.getName(); + } + + @Override + public String comment() { + return event.getComment(); + } + + @Override + public String data() { + return event.readData(); + } + }); + if (!keep) { + return; + } + } + // DO NOT pass the response mime type because it's SSE: let the event pick between the X-SSE-Content-Type header or // the content-type SSE field + if (returnSseEvent) { multiRequest.emit((R) new SseEvent() { @Override @@ -212,6 +250,23 @@ public Object data() { sseSource.registerAfterRequest(vertxResponse); } + private Predicate> createEventPredicate(Method invokedMethod) { + if (invokedMethod == null) { + return null; // should never happen + } + + SseEventFilter filterAnnotation = invokedMethod.getAnnotation(SseEventFilter.class); + if (filterAnnotation == null) { + return null; + } + + try { + return filterAnnotation.value().getConstructor().newInstance(); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + private void registerForChunks(MultiRequest multiRequest, RestClientRequestContext restClientRequestContext, GenericType responseType, From ce22dbd4edbd62973856481a1e9027b81bab3fdf Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Tue, 21 Nov 2023 10:56:57 +0100 Subject: [PATCH 10/20] Build scans - Allow publishing build scans for everyone --- .../develocity-preapproved-developers.json | 56 ------------------- .../develocity-publish-build-scans.yml | 9 --- 2 files changed, 65 deletions(-) delete mode 100644 .github/develocity-preapproved-developers.json diff --git a/.github/develocity-preapproved-developers.json b/.github/develocity-preapproved-developers.json deleted file mode 100644 index 670c4d84f00ac1..00000000000000 --- a/.github/develocity-preapproved-developers.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "preapproved-developers": [ - "alesj", - "aloubyansky", - "aureamunoz", - "brunobat", - "cescoffier", - "DavideD", - "dmlloyd", - "ebullient", - "emmanuelbernard", - "evanchooly", - "FroMage", - "galderz", - "gastaldi", - "geoand", - "gsmet", - "gwenneg", - "holly-cummins", - "ia3andy", - "iocanel", - "jmartisk", - "johnaohara", - "jponge", - "karesti", - "Karm", - "Ladicek", - "machi1990", - "manovotn", - "manusa", - "maxandersen", - "metacosm", - "MichalMaler", - "michalvavrik", - "michelle-purcell", - "MikeEdgar", - "mkouba", - "n1hility", - "ozangunalp", - "patriot1burke", - "pedroigor", - "phillip-kruger", - "ppalaga", - "radcortez", - "rsvoboda", - "Sanne", - "sberyozkin", - "Sgitario", - "stalep", - "starksm64", - "stuartwdouglas", - "tsegismont", - "yrodiere", - "zakkak" - ] -} \ No newline at end of file diff --git a/.github/workflows/develocity-publish-build-scans.yml b/.github/workflows/develocity-publish-build-scans.yml index 1add2f739280b8..c391c6bc686088 100644 --- a/.github/workflows/develocity-publish-build-scans.yml +++ b/.github/workflows/develocity-publish-build-scans.yml @@ -17,17 +17,8 @@ jobs: pull-requests: write checks: write steps: - - uses: actions/checkout@v4 - - name: Extract preapproved developers list - id: extract-preapproved-developers - run: | - echo "preapproved-developpers<> $GITHUB_OUTPUT - cat .github/develocity-preapproved-developers.json >> $GITHUB_OUTPUT - echo >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - name: Publish Maven Build Scans uses: gradle/github-actions/maven-build-scan/publish@v1-beta - if: ${{ contains(fromJson(steps.extract-preapproved-developers.outputs.preapproved-developpers).preapproved-developers, github.event.workflow_run.actor.login) }} with: develocity-url: 'https://ge.quarkus.io' develocity-access-key: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} From d2bb831548d019cf342886170ff2d437806612b0 Mon Sep 17 00:00:00 2001 From: Katia Aresti Date: Tue, 21 Nov 2023 11:05:46 +0100 Subject: [PATCH 11/20] Updates Infinispan to 14.0.21.Final --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 1e100c29d50fe1..db8651792215ea 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -139,7 +139,7 @@ 2.2 5.10.0 1.5.0 - 14.0.20.Final + 14.0.21.Final 4.6.5.Final 3.1.5 4.1.100.Final From 6f41d71bf861bb64c3404c85c1ce7856b4c6f62d Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Tue, 21 Nov 2023 12:18:26 +0200 Subject: [PATCH 12/20] Document SSE usage in REST Client --- .../main/asciidoc/rest-client-reactive.adoc | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/docs/src/main/asciidoc/rest-client-reactive.adoc b/docs/src/main/asciidoc/rest-client-reactive.adoc index 4d1af8480e4d30..b3c063bccdda2c 100644 --- a/docs/src/main/asciidoc/rest-client-reactive.adoc +++ b/docs/src/main/asciidoc/rest-client-reactive.adoc @@ -883,6 +883,107 @@ If you use a `CompletionStage`, you would need to call the service's method to r This difference comes from the laziness aspect of Mutiny and its subscription protocol. More details about this can be found in https://smallrye.io/smallrye-mutiny/latest/reference/uni-and-multi/[the Mutiny documentation]. +=== Server-Sent Event (SSE) support + +Consuming SSE events is possible simply by declaring the result type as a `io.smallrye.mutiny.Multi`. + +The simplest example is: + +[source, java] +---- +package org.acme.rest.client; + +import io.smallrye.mutiny.Multi; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +@Path("/sse") +@RegisterRestClient(configKey = "some-api") +public interface SseClient { + @GET + @Produces(MediaType.SERVER_SENT_EVENTS) + Multi get(); +} +---- + +[NOTE] +==== +All the IO involved in streaming the SSE results is done in a non-blocking manner. +==== + +Results are not limited to strings - for example when the server returns JSON payload for each event, Quarkus automatically deserializes it into the generic type used in the `Multi`. + +[TIP] +==== +Users can also access the entire SSE event by using the `org.jboss.resteasy.reactive.client.SseEvent` type. + +A simple example where the event payloads are `Long` values is the following: + +[source, java] +---- +package org.acme.rest.client; + +import io.smallrye.mutiny.Uni; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.jboss.resteasy.reactive.client.SseEvent; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; + +@Path("/sse") +@RegisterRestClient(configKey = "some-api") +public interface SseClient { + @GET + @Produces(MediaType.SERVER_SENT_EVENTS) + Multi> get(); +} +---- +==== + +==== Filtering out events + +On occasion, the stream of SSE events may contain some events that should not be returned by the client - an example of this is having the server send heartbeat events in order to keep the underlying TCP connection open. +The REST Client supports filtering out such events by providing the `@org.jboss.resteasy.reactive.client.SseEventFilter`. + +Here is an example of filtering out heartbeat events: + +[source,java] +---- +package org.acme.rest.client; + +import io.smallrye.mutiny.Uni; +import java.util.function.Predicate; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.jboss.resteasy.reactive.client.SseEvent; +import org.jboss.resteasy.reactive.client.SseEventFilter; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; + +@Path("/sse") +@RegisterRestClient(configKey = "some-api") +public interface SseClient { + + @GET + @Produces(MediaType.SERVER_SENT_EVENTS) + @SseEventFilter(HeartbeatFilter.class) + Multi> get(); + + + class HeartbeatFilter implements Predicate> { + + @Override + public boolean test(SseEvent event) { + return !"heartbeat".equals(event.id()); + } + } +} +---- + == Custom headers support There are a few ways in which you can specify custom headers for your REST calls: From f90ca79c153fb444384e057e123ef24a6411ae59 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Tue, 21 Nov 2023 11:27:38 +0100 Subject: [PATCH 13/20] Dev mode: RuntimeUpdatesProcessor - never watch directories --- .../io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java index 0a92c1909a8fe9..24a1c175a30593 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java @@ -1128,7 +1128,9 @@ private RuntimeUpdatesProcessor setWatchedFilePathsInternal(Map // First find all matching paths from all roots try (final Stream walk = Files.walk(root)) { walk.forEach(path -> { - if (path.equals(root)) { + if (path.equals(root) + // Never watch directories + || Files.isDirectory(path)) { return; } // Use the relative path to match the watched file From 81818c79e4229d1de8579dd5ca0037715417e7d8 Mon Sep 17 00:00:00 2001 From: Foivos Zakkak Date: Tue, 21 Nov 2023 12:57:34 +0200 Subject: [PATCH 14/20] Support Docker Desktop for building native executables Treat Docker Desktop as "rootless" since the way it binds mounts does not transparently map the host user ID and GID see https://docs.docker.com/desktop/faqs/linuxfaqs/#how-do-i-enable-file-sharing Closes https://github.com/quarkusio/quarkus/issues/37193 --- .../java/io/quarkus/runtime/util/ContainerRuntimeUtil.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/runtime/src/main/java/io/quarkus/runtime/util/ContainerRuntimeUtil.java b/core/runtime/src/main/java/io/quarkus/runtime/util/ContainerRuntimeUtil.java index ed538474b8b57f..607ead4f24980c 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/util/ContainerRuntimeUtil.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/util/ContainerRuntimeUtil.java @@ -191,7 +191,10 @@ private static boolean getRootlessStateFor(ContainerRuntime containerRuntime) { final Predicate stringPredicate; // Docker includes just "rootless" under SecurityOptions, while podman includes "rootless: " if (containerRuntime == ContainerRuntime.DOCKER) { - stringPredicate = line -> line.trim().equals("rootless"); + // We also treat Docker Desktop as "rootless" since the way it binds mounts does not + // transparently map the host user ID and GID + // see https://docs.docker.com/desktop/faqs/linuxfaqs/#how-do-i-enable-file-sharing + stringPredicate = line -> line.trim().equals("rootless") || line.contains("desktop-linux"); } else { stringPredicate = line -> line.trim().equals("rootless: true"); } From e45dec61524df2cd1f6c640a933e2bd35123ffaf Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Tue, 21 Nov 2023 10:38:52 +0100 Subject: [PATCH 15/20] Build cache - Only store if the access key is around --- .mvn/gradle-enterprise.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mvn/gradle-enterprise.xml b/.mvn/gradle-enterprise.xml index 0f9d7558936b53..27ac5553de6a2c 100644 --- a/.mvn/gradle-enterprise.xml +++ b/.mvn/gradle-enterprise.xml @@ -27,7 +27,7 @@ true - #{env['CI'] != null} + #{env['CI'] != null and env['GRADLE_ENTERPRISE_ACCESS_KEY'] != null and env['GRADLE_ENTERPRISE_ACCESS_KEY'] != ''} From 2c29d55a7cadc4fcd4d792aeee9e160f54961ab9 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Tue, 21 Nov 2023 12:28:56 +0000 Subject: [PATCH 16/20] Always execute a JPA password action --- .../common/deployment/JpaSecurityIdentityUtil.java | 13 ++++++++++--- .../jpa/common/runtime/JpaIdentityProviderUtil.java | 12 ++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/extensions/security-jpa-common/deployment/src/main/java/io/quarkus/security/jpa/common/deployment/JpaSecurityIdentityUtil.java b/extensions/security-jpa-common/deployment/src/main/java/io/quarkus/security/jpa/common/deployment/JpaSecurityIdentityUtil.java index 5501ea44443538..054fcb33d6e69f 100644 --- a/extensions/security-jpa-common/deployment/src/main/java/io/quarkus/security/jpa/common/deployment/JpaSecurityIdentityUtil.java +++ b/extensions/security-jpa-common/deployment/src/main/java/io/quarkus/security/jpa/common/deployment/JpaSecurityIdentityUtil.java @@ -45,18 +45,21 @@ public static void buildIdentity(Index index, JpaSecurityDefinition jpaSecurityD PanacheEntityPredicateBuildItem panacheEntityPredicate, FieldDescriptor passwordProviderField, MethodCreator outerMethod, ResultHandle userVar, BytecodeCreator innerMethod) { // if(user == null) throw new AuthenticationFailedException(); + + PasswordType passwordType = passwordTypeValue != null ? PasswordType.valueOf(passwordTypeValue.asEnum()) + : PasswordType.MCF; + try (BytecodeCreator trueBranch = innerMethod.ifNull(userVar).trueBranch()) { + ResultHandle exceptionInstance = trueBranch .newInstance(MethodDescriptor.ofConstructor(AuthenticationFailedException.class)); + trueBranch.invokeStaticMethod(passwordActionMethod(), trueBranch.load(passwordType)); trueBranch.throwException(exceptionInstance); } // :pass = user.pass | user.getPass() ResultHandle pass = jpaSecurityDefinition.password.readValue(innerMethod, userVar); - PasswordType passwordType = passwordTypeValue != null ? PasswordType.valueOf(passwordTypeValue.asEnum()) - : PasswordType.MCF; - if (passwordType == PasswordType.CUSTOM && passwordProviderValue == null) { throw new RuntimeException("Missing password provider for password type: " + passwordType); } @@ -245,4 +248,8 @@ private static MethodDescriptor getUtilMethod(String passwordProviderMethod) { return MethodDescriptor.ofMethod(JpaIdentityProviderUtil.class, passwordProviderMethod, org.wildfly.security.password.Password.class, String.class); } + + private static MethodDescriptor passwordActionMethod() { + return MethodDescriptor.ofMethod(JpaIdentityProviderUtil.class, "passwordAction", void.class, PasswordType.class); + } } diff --git a/extensions/security-jpa-common/runtime/src/main/java/io/quarkus/security/jpa/common/runtime/JpaIdentityProviderUtil.java b/extensions/security-jpa-common/runtime/src/main/java/io/quarkus/security/jpa/common/runtime/JpaIdentityProviderUtil.java index a65f771596a5dc..15a3c4710d1c89 100644 --- a/extensions/security-jpa-common/runtime/src/main/java/io/quarkus/security/jpa/common/runtime/JpaIdentityProviderUtil.java +++ b/extensions/security-jpa-common/runtime/src/main/java/io/quarkus/security/jpa/common/runtime/JpaIdentityProviderUtil.java @@ -2,6 +2,7 @@ import java.security.spec.InvalidKeySpecException; import java.util.List; +import java.util.UUID; import org.wildfly.security.credential.PasswordCredential; import org.wildfly.security.evidence.PasswordGuessEvidence; @@ -10,9 +11,11 @@ import org.wildfly.security.password.util.ModularCrypt; import org.wildfly.security.provider.util.ProviderUtil; +import io.quarkus.elytron.security.common.BcryptUtil; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.identity.request.TrustedAuthenticationRequest; import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest; +import io.quarkus.security.jpa.PasswordType; import io.quarkus.security.runtime.QuarkusPrincipal; import io.quarkus.security.runtime.QuarkusSecurityIdentity; @@ -70,4 +73,13 @@ public static Password getMcfPassword(String pass) { throw new RuntimeException(e); } } + + public static void passwordAction(PasswordType type) { + String uuid = UUID.randomUUID().toString(); + if (type == PasswordType.CLEAR) { + ClearPassword.createRaw(ClearPassword.ALGORITHM_CLEAR, uuid.toCharArray()); + } else { + BcryptUtil.bcryptHash(uuid); + } + } } From 8596fc96deb8a043f0465e0fc740b250f1375cdd Mon Sep 17 00:00:00 2001 From: Jonathan Kolberg Date: Tue, 21 Nov 2023 13:51:10 +0100 Subject: [PATCH 17/20] Update kindcontainer to 1.4.4 --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 1e100c29d50fe1..866cb28907faa8 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -211,7 +211,7 @@ 3.3.3 2.0.0 - 1.3.0 + 1.4.4 2.7 2.4 2.4.0 From f5af6b40e34ecdf7bd7596d4806b5866af02b38f Mon Sep 17 00:00:00 2001 From: Clement Escoffier Date: Tue, 21 Nov 2023 13:58:09 +0100 Subject: [PATCH 18/20] Add a test for the Duplicated Context handling in the CacheResultInterceptor --- .../DuplicatedContextHandlingTest.java | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/DuplicatedContextHandlingTest.java diff --git a/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/DuplicatedContextHandlingTest.java b/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/DuplicatedContextHandlingTest.java new file mode 100644 index 00000000000000..32229c90c02edf --- /dev/null +++ b/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/DuplicatedContextHandlingTest.java @@ -0,0 +1,178 @@ +package io.quarkus.cache.test.runtime; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.control.ActivateRequestContext; +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.cache.CacheResult; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; +import io.vertx.core.Context; +import io.vertx.core.Vertx; +import io.vertx.core.impl.ContextInternal; + +public class DuplicatedContextHandlingTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest().withApplicationRoot(jar -> jar + .addClass(CachedService.class)); + + @Inject + CachedService cachedService; + + @Inject + Vertx vertx; + + @Test + @ActivateRequestContext + void testDuplicatedContextHandlingWhenCalledFromNoContext() { + cachedService.direct(false).await().indefinitely(); + cachedService.direct(true).await().indefinitely(); + } + + @Test + @ActivateRequestContext + void testDuplicatedContextHandlingWhenCalledOnContext() throws InterruptedException { + ContextInternal context = (ContextInternal) vertx.getOrCreateContext(); + if (context.isDuplicate()) { + context = context.duplicate(); + } + + CountDownLatch latch = new CountDownLatch(1); + Context tmp = context; + context.runOnContext(x -> { + cachedService.direct(false) + .invoke(() -> { + if (!tmp.equals(Vertx.currentContext())) { + throw new AssertionError("Expected to go back on the caller context"); + } + }) + .subscribe().with(y -> latch.countDown()); + }); + Assertions.assertTrue(latch.await(1, TimeUnit.SECONDS)); + + CountDownLatch latch2 = new CountDownLatch(1); + context.runOnContext(x -> { + cachedService.direct(true) + .invoke(() -> { + if (!tmp.equals(Vertx.currentContext())) { + throw new AssertionError("Expected to go back on the caller context"); + } + }) + .subscribe().with(y -> latch2.countDown()); + }); + Assertions.assertTrue(latch2.await(1, TimeUnit.SECONDS)); + + CountDownLatch latch3 = new CountDownLatch(1); + context.runOnContext(x -> { + cachedService.direct(false) + .invoke(() -> { + if (!tmp.equals(Vertx.currentContext())) { + throw new AssertionError("Expected to go back on the caller context"); + } + }) + .subscribe().with(y -> latch3.countDown()); + }); + Assertions.assertTrue(latch3.await(1, TimeUnit.SECONDS)); + + } + + @Test + @ActivateRequestContext + void testDuplicatedContextHandlingWhenCalledOnDifferentContexts() throws InterruptedException { + ContextInternal context = (ContextInternal) vertx.getOrCreateContext(); + context = context.duplicate(); + var context2 = context.duplicate(); + + CountDownLatch latch = new CountDownLatch(1); + Context tmp = context; + context.runOnContext(x -> { + cachedService.direct(false) + .invoke(() -> { + if (!tmp.equals(Vertx.currentContext())) { + throw new AssertionError("Expected to go back on the caller context"); + } + }) + .subscribe().with(y -> latch.countDown()); + }); + Assertions.assertTrue(latch.await(1, TimeUnit.SECONDS)); + + CountDownLatch latch2 = new CountDownLatch(1); + context2.runOnContext(x -> { + cachedService.direct(false) + .invoke(() -> { + if (!context2.equals(Vertx.currentContext())) { + throw new AssertionError("Expected to go back on the caller context"); + } + }) + .subscribe().with(y -> latch2.countDown()); + }); + Assertions.assertTrue(latch2.await(1, TimeUnit.SECONDS)); + } + + @Test + @ActivateRequestContext + void testDuplicatedContextHandlingWhenCalledContextAndAnsweredFromAnotherContext() throws InterruptedException { + ContextInternal context = (ContextInternal) vertx.getOrCreateContext(); + context = context.duplicate(); + var context2 = context.duplicate(); + + CountDownLatch latch = new CountDownLatch(1); + Context tmp = context; + context.runOnContext(x -> { + cachedService.directOnAnotherContext(false) + .invoke(() -> { + if (!tmp.equals(Vertx.currentContext())) { + throw new AssertionError("Expected to go back on the caller context"); + } + }) + .subscribe().with(y -> latch.countDown()); + }); + Assertions.assertTrue(latch.await(1, TimeUnit.SECONDS)); + + CountDownLatch latch2 = new CountDownLatch(1); + context2.runOnContext(x -> { + cachedService.directOnAnotherContext(false) + .invoke(() -> { + if (!context2.equals(Vertx.currentContext())) { + throw new AssertionError("Expected to go back on the caller context"); + } + }) + .subscribe().with(y -> latch2.countDown()); + }); + Assertions.assertTrue(latch2.await(1, TimeUnit.SECONDS)); + } + + @ApplicationScoped + public static class CachedService { + + volatile boolean timedout = false; + + @CacheResult(cacheName = "duplicated-context-cache", lockTimeout = 100) + public Uni direct(boolean timeout) { + if (!timeout || timedout) { + return Uni.createFrom().item("foo"); + } + timedout = true; + return Uni.createFrom().nothing(); + } + + @CacheResult(cacheName = "duplicated-context-cache", lockTimeout = 100) + public Uni directOnAnotherContext(boolean timeout) { + if (!timeout || timedout) { + return Uni.createFrom().item("foo") + .emitOn(c -> ((ContextInternal) Vertx.currentContext().owner()).duplicate().runOnContext(x -> c.run())); + } + timedout = true; + return Uni.createFrom().nothing(); + } + } + +} From 325a1a8dc1a1ce6f17415d85bbfb877ff0fe5a64 Mon Sep 17 00:00:00 2001 From: Jerome Prinet Date: Mon, 9 Oct 2023 15:58:28 +0200 Subject: [PATCH 19/20] Check current Maven version is aligned with wrapper Maven version --- .../gradle-enterprise-custom-user-data.groovy | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.mvn/gradle-enterprise-custom-user-data.groovy b/.mvn/gradle-enterprise-custom-user-data.groovy index a076e487182941..8e78dcf99e5977 100644 --- a/.mvn/gradle-enterprise-custom-user-data.groovy +++ b/.mvn/gradle-enterprise-custom-user-data.groovy @@ -96,3 +96,23 @@ if (System.env.GITHUB_ACTIONS) { } } +// Check runtime Maven version and Maven Wrapper version are aligned +def runtimeInfo = (org.apache.maven.rtinfo.RuntimeInformation) session.lookup("org.apache.maven.rtinfo.RuntimeInformation") +def runtimeMavenVersion = runtimeInfo?.getMavenVersion() +Properties mavenWrapperProperties = new Properties() +File mavenWrapperPropertiesFile = new File(".mvn/wrapper/maven-wrapper.properties") +if(mavenWrapperPropertiesFile.exists()) { + mavenWrapperPropertiesFile.withInputStream { + mavenWrapperProperties.load(it) + } + // assuming the wrapper properties contains: + // distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/VERSION/apache-maven-VERSION-bin.zip + if(regexp = mavenWrapperProperties."distributionUrl" =~ /.*\/apache-maven-(.*)-bin\.zip/) { + def wrapperMavenVersion = regexp.group(1) + if (runtimeMavenVersion && wrapperMavenVersion && wrapperMavenVersion != runtimeMavenVersion) { + log.warn("Maven Wrapper is configured with a different version (" + wrapperMavenVersion + ") than the runtime version (" + runtimeMavenVersion + "). This will negatively impact build consistency and build caching.") + buildScan.tag("misaligned-maven-version") + buildScan.value("wrapper-maven-version", wrapperMavenVersion) + } + } +} From bae0f038fd9ced46fba56d01e255036f56e01290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Wed, 22 Nov 2023 00:49:58 +0100 Subject: [PATCH 20/20] Support repeated wildcards in the HTTP permissions paths --- ...ity-authorize-web-endpoints-reference.adoc | 59 +- .../PathMatchingHttpSecurityPolicyTest.java | 31 ++ ...bstractPathMatchingHttpSecurityPolicy.java | 40 +- .../security/ImmutablePathMatcher.java | 346 ++++++++++++ .../security/ImmutableSubstringMap.java | 135 +++++ .../http/runtime/security/PathMatcher.java | 11 +- .../http/runtime/security/SubstringMap.java | 85 ++- .../vertx/http/runtime/PathMatcherTest.java | 511 ++++++++++++++++++ 8 files changed, 1142 insertions(+), 76 deletions(-) create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutablePathMatcher.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutableSubstringMap.java create mode 100644 extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/PathMatcherTest.java diff --git a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc index 41a9d39bf3feda..698225dd0f3a31 100644 --- a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc +++ b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc @@ -68,6 +68,8 @@ It is an exact path match because it does not end with `*`. <3> This permission set references the previously defined policy. `roles1` is an example name; you can call the permission sets whatever you want. +WARNING: The `/forbidden` exact path in the example above will not secure the `/forbidden/` path. Don't forget to add new exact path for the `/forbidden/` path. + === Custom HttpSecurityPolicy Sometimes it might be useful to register your own named policy. You can get it done by creating application scoped CDI @@ -123,10 +125,12 @@ Otherwise, it queries for an exact match and only matches that specific path: [source,properties] ---- -quarkus.http.auth.permission.permit1.paths=/public/*,/css/*,/js/*,/robots.txt +quarkus.http.auth.permission.permit1.paths=/public*,/css/*,/js/*,/robots.txt <1> quarkus.http.auth.permission.permit1.policy=permit quarkus.http.auth.permission.permit1.methods=GET,HEAD ---- +<1> The `$$*$$` wildcard at the end of the path matches zero or more path segments, but never any word starting from the `/public` path. +For that reason, a path like `/public-info` is not matched by this pattern. === Matching a path but not a method @@ -170,6 +174,59 @@ quarkus.http.auth.permission.public.policy=permit ---- ==== +=== Matching multiple sub-paths: longest path to the `*` wildcard wins + +Previous examples shown how you can match all sub-paths when a path ends with the `$$*$$` wildcard. +The `$$*$$` wildcard can also be used in the middle of the path, in which case it represents exactly one path segment. +You can't combine this wildcard with any other path segment character, therefore the `$$*$$` wildcard will always be +enclosed with path separators as in the `/public/$$*$$/about-us` path. + +What happens if multiple path patterns matches same request path? +Matching is always done on the "longest sub-path to the `$$*$$` wildcard wins" basis. +Every path segment character is considered more specific than the `$$*$$` wildcard. + +Here is a simple example: + +[source,properties] +---- +quarkus.http.auth.permission.secured.paths=/api/*/detail <1> +quarkus.http.auth.permission.secured.policy=authenticated +quarkus.http.auth.permission.public.paths=/api/public-product/detail <2> +quarkus.http.auth.permission.public.policy=permit +---- +<1> Request paths like `/api/product/detail` can only be accessed by authenticated users. +<2> The path `/api/public-product/detail` is more specific, therefore accessible by anyone. + +[IMPORTANT] +==== +All paths secured with the authorization using configuration should be tested. +Writing path patterns with multiple wildcards can be cumbersome. +Please make sure paths are authorized as you intended. +==== + +In the following example, paths are ordered from the most specific to the least specific one: + +.Request path `/one/two/three/four/five` matches ordered from the most specific to the least specific path + +[source, text] +---- +/one/two/three/four/five +/one/two/three/four/* +/one/two/three/*/five +/one/two/three/*/* +/one/two/*/four/five +/one/*/three/four/five +/*/two/three/four/five +/*/two/three/*/five +/* +---- + +[IMPORTANT] +==== +The `$$*$$` wildcard at the end of the path matches zero or more path segments. +The `$$*$$` wildcard placed anywhere else matches exactly one path segment. +==== + === Matching multiple paths: most specific method wins When a path is registered with multiple permission sets, the permission sets explicitly specifying an HTTP method that matches the request take precedence. diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathMatchingHttpSecurityPolicyTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathMatchingHttpSecurityPolicyTest.java index 08679d345bcaa2..3169e6bb6067dc 100644 --- a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathMatchingHttpSecurityPolicyTest.java +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathMatchingHttpSecurityPolicyTest.java @@ -41,6 +41,18 @@ public class PathMatchingHttpSecurityPolicyTest { "quarkus.http.auth.permission.public.policy=permit\n" + "quarkus.http.auth.permission.foo.paths=/api/foo/bar\n" + "quarkus.http.auth.permission.foo.policy=authenticated\n" + + "quarkus.http.auth.permission.inner-wildcard.paths=/api/*/bar\n" + + "quarkus.http.auth.permission.inner-wildcard.policy=authenticated\n" + + "quarkus.http.auth.permission.inner-wildcard2.paths=/api/next/*/prev\n" + + "quarkus.http.auth.permission.inner-wildcard2.policy=authenticated\n" + + "quarkus.http.auth.permission.inner-wildcard3.paths=/api/one/*/three/*\n" + + "quarkus.http.auth.permission.inner-wildcard3.policy=authenticated\n" + + "quarkus.http.auth.permission.inner-wildcard4.paths=/api/one/*/*/five\n" + + "quarkus.http.auth.permission.inner-wildcard4.policy=authenticated\n" + + "quarkus.http.auth.permission.inner-wildcard5.paths=/api/one/*/jamaica/*\n" + + "quarkus.http.auth.permission.inner-wildcard5.policy=permit\n" + + "quarkus.http.auth.permission.inner-wildcard6.paths=/api/*/sadly/*/dont-know\n" + + "quarkus.http.auth.permission.inner-wildcard6.policy=deny\n" + "quarkus.http.auth.permission.baz.paths=/api/baz\n" + "quarkus.http.auth.permission.baz.policy=authenticated\n" + "quarkus.http.auth.permission.static-resource.paths=/static-file.html\n" + @@ -85,6 +97,25 @@ private WebClient getClient() { return client; } + @Test + public void testInnerWildcardPath() { + assurePath("/api/any-value/bar", 401); + assurePath("/api/any-value/bar", 401); + assurePath("/api/next/any-value/prev", 401); + assurePath("/api/one/two/three/four", 401); + assurePath("/api////any-value//////bar", 401); + assurePath("/api/next///////any-value////prev", 401); + assurePath("////api//one/two//three////four?door=wood", 401); + assurePath("/api/one/three/four/five", 401); + assurePath("/api/one/3/4/five", 401); + assurePath("////api/one///3/4/five", 401); + assurePath("/api/now/sadly/i/dont-know", 401); + assurePath("/api/now/sadly///i/dont-know", 401); + assurePath("/api/one/three/jamaica/five", 200); + assurePath("/api/one/three/jamaica/football", 200); + assurePath("/api/now/sally/i/dont-know", 200); + } + @ParameterizedTest @ValueSource(strings = { // path policy without wildcard diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java index 304688d56f51d8..3371e6c3651625 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java @@ -23,6 +23,7 @@ import io.quarkus.vertx.http.runtime.PolicyMappingConfig; import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy.AuthorizationRequestContext; import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy.CheckResult; +import io.quarkus.vertx.http.runtime.security.ImmutablePathMatcher.PathMatch; import io.smallrye.mutiny.Uni; import io.vertx.ext.web.RoutingContext; @@ -33,15 +34,15 @@ */ public class AbstractPathMatchingHttpSecurityPolicy { - private final PathMatcher> pathMatcher = new PathMatcher<>(); + private final ImmutablePathMatcher> pathMatcher; AbstractPathMatchingHttpSecurityPolicy(Map permissions, Map rolePolicy, String rootPath, Instance installedPolicies) { - init(permissions, toNamedHttpSecPolicies(rolePolicy, installedPolicies), rootPath); + pathMatcher = init(permissions, toNamedHttpSecPolicies(rolePolicy, installedPolicies), rootPath); } public String getAuthMechanismName(RoutingContext routingContext) { - PathMatcher.PathMatch> toCheck = pathMatcher.match(routingContext.normalizedPath()); + PathMatch> toCheck = pathMatcher.match(routingContext.normalizedPath()); if (toCheck.getValue() == null || toCheck.getValue().isEmpty()) { return null; } @@ -93,9 +94,9 @@ public Uni apply(CheckResult checkResult) { }); } - private void init(Map permissions, + private static ImmutablePathMatcher> init(Map permissions, Map permissionCheckers, String rootPath) { - Map> tempMap = new HashMap<>(); + final var builder = ImmutablePathMatcher.> builder().handlerAccumulator(List::addAll); for (Map.Entry entry : permissions.entrySet()) { HttpSecurityPolicy checker = permissionCheckers.get(entry.getValue().policy); if (checker == null) { @@ -108,34 +109,19 @@ private void init(Map permissions, if (!path.startsWith("/")) { path = rootPath + path; } - if (tempMap.containsKey(path)) { - HttpMatcher m = new HttpMatcher(entry.getValue().authMechanism.orElse(null), - new HashSet<>(entry.getValue().methods.orElse(Collections.emptyList())), - checker); - tempMap.get(path).add(m); - } else { - HttpMatcher m = new HttpMatcher(entry.getValue().authMechanism.orElse(null), - new HashSet<>(entry.getValue().methods.orElse(Collections.emptyList())), - checker); - List perms = new ArrayList<>(); - tempMap.put(path, perms); - perms.add(m); - if (path.endsWith("/*")) { - String stripped = path.substring(0, path.length() - 2); - pathMatcher.addPrefixPath(stripped.isEmpty() ? "/" : stripped, perms); - } else if (path.endsWith("*")) { - pathMatcher.addPrefixPath(path.substring(0, path.length() - 1), perms); - } else { - pathMatcher.addExactPath(path, perms); - } - } + HttpMatcher m = new HttpMatcher(entry.getValue().authMechanism.orElse(null), + new HashSet<>(entry.getValue().methods.orElse(Collections.emptyList())), checker); + List perms = new ArrayList<>(); + perms.add(m); + builder.addPath(path, perms); } } } + return builder.build(); } public List findPermissionCheckers(RoutingContext context) { - PathMatcher.PathMatch> toCheck = pathMatcher.match(context.normalizedPath()); + PathMatch> toCheck = pathMatcher.match(context.normalizedPath()); if (toCheck.getValue() == null || toCheck.getValue().isEmpty()) { return Collections.emptyList(); } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutablePathMatcher.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutablePathMatcher.java new file mode 100644 index 00000000000000..1778c24d81a947 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutablePathMatcher.java @@ -0,0 +1,346 @@ +package io.quarkus.vertx.http.runtime.security; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.BiConsumer; + +import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.SubstringMatch; + +/** + * Handler that dispatches to a given handler based on a match of the path. + */ +public class ImmutablePathMatcher { + + private final ImmutableSubstringMap paths; + private final Map exactPathMatches; + + /** + * lengths of all registered paths + */ + private final int[] lengths; + private final T defaultHandler; + private final boolean hasPathWithInnerWildcard; + private final boolean hasExactPathMatches; + + private ImmutablePathMatcher(T defaultHandler, ImmutableSubstringMap paths, Map exactPathMatches, + int[] lengths, boolean hasPathWithInnerWildcard) { + this.defaultHandler = defaultHandler; + this.paths = paths; + this.lengths = Arrays.copyOf(lengths, lengths.length); + this.hasPathWithInnerWildcard = hasPathWithInnerWildcard; + if (exactPathMatches.isEmpty()) { + this.exactPathMatches = null; + this.hasExactPathMatches = false; + } else { + this.exactPathMatches = Map.copyOf(exactPathMatches); + this.hasExactPathMatches = true; + } + } + + /** + * Matches a path against the registered handlers. + * + * @param path The relative path to match + * @return The match. This will never be null, however if none matched its value field will be + */ + public PathMatch match(String path) { + if (hasExactPathMatches) { + T match = exactPathMatches.get(path); + if (match != null) { + return new PathMatch<>(path, "", match); + } + } + + int length = path.length(); + for (int pathLength : lengths) { + if (pathLength == length) { + SubstringMatch next = paths.get(path, length); + if (next != null) { + return new PathMatch<>(path, "", next.getValue()); + } + } else if (pathLength < length) { + char c = path.charAt(pathLength); + // pathLength == 1 means prefix path is / because prefix path always starts with / + // which means it's default handler match, but if there is at least + // one path with inner wildcard, we need to check for paths like /*/one + if (c == '/' || (hasPathWithInnerWildcard && pathLength == 1)) { + + //String part = path.substring(0, pathLength); + SubstringMatch next = paths.get(path, pathLength); + if (next != null) { + return new PathMatch<>(next.getKey(), path.substring(pathLength), next.getValue()); + } + } + } + } + return new PathMatch<>("", path, defaultHandler); + } + + public static ImmutablePathMatcherBuilder builder() { + return new ImmutablePathMatcherBuilder<>(); + } + + public static final class PathMatch { + private final String matched; + private final String remaining; + private final T value; + + public PathMatch(String matched, String remaining, T value) { + this.matched = matched; + this.remaining = remaining; + this.value = value; + } + + /** + * @deprecated because it can't be supported with inner wildcard without cost. It's unlikely this method is + * used by anyone as users don't get in touch with this class. If there is legit use case, please + * open Quarkus issue. + */ + @Deprecated + public String getRemaining() { + return remaining; + } + + public String getMatched() { + return matched; + } + + public T getValue() { + return value; + } + } + + public static class ImmutablePathMatcherBuilder { + + private static final String STRING_PATH_SEPARATOR = "/"; + private final Map exactPathMatches = new HashMap<>(); + private final Map> pathsWithWildcard = new HashMap<>(); + private BiConsumer handlerAccumulator; + + private ImmutablePathMatcherBuilder() { + } + + /** + * @param handlerAccumulator policies defined with same path are accumulated, this way, you can define + * more than one policy of one path (e.g. one for POST method, one for GET method) + * @return ImmutablePathMatcherBuilder + */ + public ImmutablePathMatcherBuilder handlerAccumulator(BiConsumer handlerAccumulator) { + this.handlerAccumulator = handlerAccumulator; + return this; + } + + public ImmutablePathMatcher build() { + T defaultHandler = null; + SubstringMap paths = new SubstringMap<>(); + boolean hasPathWithInnerWildcard = false; + // process paths with a wildcard first, that way we only create inner path matcher when really needed + for (Path p : pathsWithWildcard.values()) { + T handler = null; + ImmutablePathMatcher> subPathMatcher = null; + + if (p.prefixPathHandler != null) { + handler = p.prefixPathHandler; + if (STRING_PATH_SEPARATOR.equals(p.path)) { + defaultHandler = p.prefixPathHandler; + } + } + + if (p.pathsWithInnerWildcard != null) { + if (!hasPathWithInnerWildcard) { + hasPathWithInnerWildcard = true; + } + // create path matcher for sub-path after inner wildcard: /one/*/three/four => /three/four + var builder = new ImmutablePathMatcherBuilder>(); + if (handlerAccumulator != null) { + builder.handlerAccumulator( + new BiConsumer, SubstringMatch>() { + @Override + public void accept(SubstringMatch match1, SubstringMatch match2) { + if (match2.hasSubPathMatcher()) { + // this should be impossible to happen since these matches are created + // right in this 'build()' method, but let's make sure of that + throw new IllegalStateException( + String.format("Failed to merge sub-matches with key '%s' for path '%s'", + match1.getKey(), p.originalPath)); + } + handlerAccumulator.accept(match1.getValue(), match2.getValue()); + } + }); + } + for (PathWithInnerWildcard p1 : p.pathsWithInnerWildcard) { + builder.addPath(p.originalPath, p1.remaining, new SubstringMatch<>(p1.remaining, p1.handler)); + } + subPathMatcher = builder.build(); + } + + paths.put(p.path, handler, subPathMatcher); + } + int[] lengths = buildLengths(paths.keys()); + return new ImmutablePathMatcher<>(defaultHandler, paths.asImmutableMap(), exactPathMatches, lengths, + hasPathWithInnerWildcard); + } + + /** + * Two sorts of paths are accepted: + * - exact path matches (without wildcard); these are matched first and Quarkus does no magic, + * request path must exactly match + * - paths with one or more wildcard: + * - ending wildcard matches zero or more path segment + * - inner wildcard matches exactly one path segment + * few notes: + * - it's key to understand only segments are matched, for example '/one*' will not match request path '/ones' + * - path patterns '/one*' and '/one/*' are one and the same thing as we only match path segments and '/one*' + * in fact means 'either /one or /one/any-number-of-path-segments' + * - paths are matched on longer-prefix-wins basis + * - what we call 'prefix' is in fact path to the first wildcard + * - if there is a path after first wildcard like in the '/one/*\/three' pattern ('/three' is remainder) + * path pattern is considered longer than the '/one/*' pattern and wins for request path '/one/two/three' + * - more specific pattern wins and wildcard is always less specific than any other path segment character, + * therefore path '/one/two/three*' will win over '/one/*\/three*' for request path '/one/two/three/four' + * + * @param path normalized path + * @param handler prefix path handler + * @return self + */ + public ImmutablePathMatcherBuilder addPath(String path, T handler) { + return addPath(path, path, handler); + } + + private ImmutablePathMatcherBuilder addPath(String originalPath, String path, T handler) { + if (!path.startsWith("/")) { + String errMsg = "Path must always start with a path separator, but was '" + path + "'"; + if (!originalPath.equals(path)) { + errMsg += " created from original path pattern '" + originalPath + "'"; + } + throw new IllegalArgumentException(errMsg); + } + final int wildcardIdx = path.indexOf('*'); + if (wildcardIdx == -1) { + addExactPath(path, handler); + } else { + addWildcardPath(path, handler, wildcardIdx, originalPath); + } + return this; + } + + private void addWildcardPath(String path, T handler, int wildcardIdx, String originalPath) { + final int lastIdx = path.length() - 1; + final String pathWithWildcard; + final String pathAfter1stWildcard; + + if (lastIdx == wildcardIdx) { + // ends with a wildcard => it's a prefix path + pathWithWildcard = path; + pathAfter1stWildcard = null; + } else { + // contains at least one inner wildcard: /one/*/three, /one/two/*/four/*, ... + // the inner wildcard represents exactly one path segment + pathWithWildcard = path.substring(0, wildcardIdx + 1); + pathAfter1stWildcard = path.substring(wildcardIdx + 1); + + // validate that inner wildcard is enclosed with path separators like: /one/*/two + // anything like: /one*/two, /one/*two/, /one/tw*o/ is not allowed + if (!pathWithWildcard.endsWith("/*") || !pathAfter1stWildcard.startsWith("/")) { + throw new ConfigurationException("HTTP permission path '" + originalPath + "' contains inner " + + "wildcard enclosed with a path character other than a separator. The inner wildcard " + + "must represent exactly one path segment. Please see this Quarkus guide for more " + + "information: https://quarkus.io/guides/security-authorize-web-endpoints-reference"); + } + } + + final String pathWithoutWildcard; + if (pathWithWildcard.endsWith("/*")) { + // remove /* + String stripped = pathWithWildcard.substring(0, pathWithWildcard.length() - 2); + pathWithoutWildcard = stripped.isEmpty() ? "/" : stripped; + } else { + // remove * + pathWithoutWildcard = pathWithWildcard.substring(0, pathWithWildcard.length() - 1); + } + + Path p = pathsWithWildcard.computeIfAbsent(pathWithoutWildcard, Path::new); + p.originalPath = originalPath; + if (pathAfter1stWildcard == null) { + p.addPrefixPath(handler, handlerAccumulator); + } else { + p.addPathWithInnerWildcard(pathAfter1stWildcard, handler); + } + } + + private void addExactPath(final String path, final T handler) { + if (path.isEmpty()) { + throw new IllegalArgumentException("Path not specified"); + } + if (exactPathMatches.containsKey(path) && handlerAccumulator != null) { + handlerAccumulator.accept(exactPathMatches.get(path), handler); + } else { + exactPathMatches.put(path, handler); + } + } + + private static int[] buildLengths(Iterable keys) { + final Set lengths = new TreeSet<>(new Comparator() { + @Override + public int compare(Integer o1, Integer o2) { + return -o1.compareTo(o2); + } + }); + for (String p : keys) { + lengths.add(p.length()); + } + + int[] lengthArray = new int[lengths.size()]; + int pos = 0; + for (int i : lengths) { + lengthArray[pos++] = i; + } + return lengthArray; + } + } + + private static class Path { + private final String path; + private String originalPath = null; + private T prefixPathHandler = null; + private List> pathsWithInnerWildcard = null; + + private Path(String path) { + this.path = path; + } + + private void addPathWithInnerWildcard(String remaining, T handler) { + if (pathsWithInnerWildcard == null) { + pathsWithInnerWildcard = new ArrayList<>(); + } + pathsWithInnerWildcard.add(new PathWithInnerWildcard<>(remaining, handler)); + } + + public void addPrefixPath(T prefixPathHandler, BiConsumer handlerAccumulator) { + Objects.requireNonNull(prefixPathHandler); + if (this.prefixPathHandler != null && handlerAccumulator != null) { + handlerAccumulator.accept(this.prefixPathHandler, prefixPathHandler); + } else { + this.prefixPathHandler = prefixPathHandler; + } + } + } + + private static class PathWithInnerWildcard { + private final String remaining; + private final T handler; + + private PathWithInnerWildcard(String remaining, T handler) { + this.remaining = remaining; + this.handler = handler; + } + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutableSubstringMap.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutableSubstringMap.java new file mode 100644 index 00000000000000..fd0e572b83cfd6 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutableSubstringMap.java @@ -0,0 +1,135 @@ +package io.quarkus.vertx.http.runtime.security; + +import java.util.Arrays; + +import io.quarkus.vertx.http.runtime.security.ImmutablePathMatcher.PathMatch; + +/** + * A string keyed map that can be accessed as a substring, eliminating the need to allocate a new string + * to do a key comparison against. + */ +public class ImmutableSubstringMap { + + private static final int ALL_BUT_LAST_BIT = ~1; + private final Object[] table; + + ImmutableSubstringMap(Object[] table) { + this.table = Arrays.copyOf(table, table.length); + } + + @SuppressWarnings("unchecked") + public SubstringMatch get(String key, int length) { + if (key.length() < length) { + throw new IllegalArgumentException(); + } + int hash = hash(key, length); + int pos = tablePos(table, hash); + int start = pos; + while (table[pos] != null) { + if (doEquals((String) table[pos], key, length)) { + SubstringMatch match = (SubstringMatch) table[pos + 1]; + if (match == null) { + return null; + } + if (match.hasSubPathMatcher) { + // consider request path '/one/two/three/four/five' + // 'match.key' (which is prefix path) never ends with a slash, e.g. 'match.key=/one/two' + // which means index 'match.key.length()' is index of the last char of the '/one/two/' sub-path + // considering we are looking for a path segment after '/one/two/*', that is the first char + // of the '/four/five' sub-path, the separator index must be greater than 'match.key.length() + 1' + if (key.length() > (match.key.length() + 1)) { + // let say match key is '/one/two' + // then next path segment is '/four' and '/three' is skipped + // for path pattern was like: '/one/two/*/four/five' + int nextPathSegmentIdx = key.indexOf('/', match.key.length() + 1); + if (nextPathSegmentIdx != -1) { + // following the example above, 'nextPath' would be '/four/five' + // and * matched 'three' path segment characters + String nextPath = key.substring(nextPathSegmentIdx); + PathMatch> subMatch = match.subPathMatcher.match(nextPath); + if (subMatch.getValue() != null) { + return subMatch.getValue(); + } + } + } + + if (match.value == null) { + // paths with inner wildcard didn't match + // and there is no prefix path with ending wildcard either + return null; + } + } + // prefix path with ending wildcard: /one/two* + return match; + } + pos += 2; + if (pos >= table.length) { + pos = 0; + } + if (pos == start) { + return null; + } + } + return null; + } + + static int tablePos(Object[] table, int hash) { + return (hash & (table.length - 1)) & ALL_BUT_LAST_BIT; + } + + static boolean doEquals(String s1, String s2, int length) { + if (s1.length() != length || s2.length() < length) { + return false; + } + for (int i = 0; i < length; ++i) { + if (s1.charAt(i) != s2.charAt(i)) { + return false; + } + } + return true; + } + + static int hash(String value, int length) { + if (length == 0) { + return 0; + } + int h = 0; + for (int i = 0; i < length; i++) { + h = 31 * h + value.charAt(i); + } + return h; + } + + public static final class SubstringMatch { + private final String key; + private final V value; + private final boolean hasSubPathMatcher; + private final ImmutablePathMatcher> subPathMatcher; + + SubstringMatch(String key, V value) { + this.key = key; + this.value = value; + this.subPathMatcher = null; + this.hasSubPathMatcher = false; + } + + SubstringMatch(String key, V value, ImmutablePathMatcher> subPathMatcher) { + this.key = key; + this.value = value; + this.subPathMatcher = subPathMatcher; + this.hasSubPathMatcher = subPathMatcher != null; + } + + public String getKey() { + return key; + } + + public V getValue() { + return value; + } + + boolean hasSubPathMatcher() { + return hasSubPathMatcher; + } + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PathMatcher.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PathMatcher.java index 032a9f91fe1185..c069fe2645a0cd 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PathMatcher.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PathMatcher.java @@ -7,6 +7,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.SubstringMatch; + /** * Handler that dispatches to a given handler based of a prefix match of the path. *

@@ -16,7 +18,10 @@ *

* * @author Stuart Douglas + * + * @deprecated use {@link ImmutablePathMatcher} instead */ +@Deprecated public class PathMatcher { private static final String STRING_PATH_SEPARATOR = "/"; @@ -55,7 +60,7 @@ public PathMatch match(String path) { final int[] lengths = this.lengths; for (int pathLength : lengths) { if (pathLength == length) { - SubstringMap.SubstringMatch next = paths.get(path, length); + SubstringMatch next = paths.get(path, length); if (next != null) { return new PathMatch<>(path, "", next.getValue()); } @@ -64,7 +69,7 @@ public PathMatch match(String path) { if (c == '/') { //String part = path.substring(0, pathLength); - SubstringMap.SubstringMatch next = paths.get(path, pathLength); + SubstringMatch next = paths.get(path, pathLength); if (next != null) { return new PathMatch<>(next.getKey(), path.substring(pathLength), next.getValue()); } @@ -117,7 +122,7 @@ public T getExactPath(final String path) { public T getPrefixPath(final String path) { // enable the prefix path mechanism to return the default handler - SubstringMap.SubstringMatch match = paths.get(path); + SubstringMatch match = paths.get(path); if (PathMatcher.STRING_PATH_SEPARATOR.equals(path) && match == null) { return this.defaultHandler; } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/SubstringMap.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/SubstringMap.java index 8ee402e48e220b..75867de490fe0f 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/SubstringMap.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/SubstringMap.java @@ -1,10 +1,16 @@ package io.quarkus.vertx.http.runtime.security; +import static io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.doEquals; +import static io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.hash; +import static io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.tablePos; + import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.NoSuchElementException; +import io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.SubstringMatch; + /** * A string keyed map that can be accessed as a substring, eliminating the need to allocate a new string * to do a key comparison against. @@ -17,19 +23,27 @@ * @author Stuart Douglas */ public class SubstringMap { - private static final int ALL_BUT_LAST_BIT = ~1; private volatile Object[] table = new Object[16]; private int size; + /** + * @deprecated use {@link ImmutablePathMatcher} + */ + @Deprecated public SubstringMatch get(String key, int length) { return get(key, length, false); } + /** + * @deprecated use {@link ImmutablePathMatcher} + */ + @Deprecated public SubstringMatch get(String key) { return get(key, key.length(), false); } + @SuppressWarnings("unchecked") private SubstringMatch get(String key, int length, boolean exact) { if (key.length() < length) { throw new IllegalArgumentException(); @@ -59,26 +73,19 @@ private SubstringMatch get(String key, int length, boolean exact) { return null; } - private int tablePos(Object[] table, int hash) { - return (hash & (table.length - 1)) & ALL_BUT_LAST_BIT; - } - - private boolean doEquals(String s1, String s2, int length) { - if (s1.length() != length || s2.length() < length) { - return false; - } - for (int i = 0; i < length; ++i) { - if (s1.charAt(i) != s2.charAt(i)) { - return false; - } - } - return true; + /** + * @deprecated use {@link ImmutablePathMatcher} + */ + @Deprecated + public synchronized void put(String key, V value) { + put(key, value, null); } - public synchronized void put(String key, V value) { + void put(String key, V value, ImmutablePathMatcher> subPathMatcher) { if (key == null) { throw new NullPointerException(); } + Object[] newTable; if (table.length / (double) size < 4 && table.length != Integer.MAX_VALUE) { newTable = new Object[table.length << 1]; @@ -91,11 +98,15 @@ public synchronized void put(String key, V value) { newTable = new Object[table.length]; System.arraycopy(table, 0, newTable, 0, table.length); } - doPut(newTable, key, new SubstringMap.SubstringMatch<>(key, value)); + doPut(newTable, key, new SubstringMatch<>(key, value, subPathMatcher)); this.table = newTable; size++; } + /** + * @deprecated use {@link ImmutablePathMatcher} + */ + @Deprecated public synchronized V remove(String key) { if (key == null) { throw new NullPointerException(); @@ -133,33 +144,30 @@ private void doPut(Object[] newTable, String key, Object value) { newTable[pos + 1] = value; } + /** + * @deprecated use {@link ImmutablePathMatcher} + */ + @Deprecated public Map toMap() { Map map = new HashMap<>(); Object[] t = this.table; for (int i = 0; i < t.length; i += 2) { if (t[i] != null) { - map.put((String) t[i], ((SubstringMatch) t[i + 1]).value); + map.put((String) t[i], ((SubstringMatch) t[i + 1]).getValue()); } } return map; } + /** + * @deprecated use {@link ImmutablePathMatcher} + */ + @Deprecated public synchronized void clear() { size = 0; table = new Object[16]; } - private static int hash(String value, int length) { - if (length == 0) { - return 0; - } - int h = 0; - for (int i = 0; i < length; i++) { - h = 31 * h + value.charAt(i); - } - return h; - } - public Iterable keys() { return new Iterable() { @Override @@ -206,21 +214,8 @@ public void remove() { } - public static final class SubstringMatch { - private final String key; - private final V value; - - public SubstringMatch(String key, V value) { - this.key = key; - this.value = value; - } - - public String getKey() { - return key; - } - - public V getValue() { - return value; - } + ImmutableSubstringMap asImmutableMap() { + return new ImmutableSubstringMap<>(table); } + } diff --git a/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/PathMatcherTest.java b/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/PathMatcherTest.java new file mode 100644 index 00000000000000..9cc89a3e3bd324 --- /dev/null +++ b/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/PathMatcherTest.java @@ -0,0 +1,511 @@ +package io.quarkus.vertx.http.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.vertx.http.runtime.security.ImmutablePathMatcher; + +public class PathMatcherTest { + + private static final Object HANDLER = new Object(); + + @Test + public void testPrefixPathWithEndingWildcard() { + ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/one/two/*", HANDLER).build(); + assertMatched(matcher, "/one/two"); + assertMatched(matcher, "/one/two/"); + assertMatched(matcher, "/one/two/three"); + assertNotMatched(matcher, "/one/twothree"); + assertNotMatched(matcher, "/one/tw"); + assertNotMatched(matcher, "/one"); + assertNotMatched(matcher, "/"); + assertNotMatched(matcher, ""); + final Object exactPathMatcher1 = new Object(); + final Object exactPathMatcher2 = new Object(); + final Object exactPathMatcher3 = new Object(); + final Object prefixPathMatcher1 = new Object(); + final Object prefixPathMatcher2 = new Object(); + matcher = ImmutablePathMatcher.builder().addPath("/one/two/*", prefixPathMatcher1) + .addPath("/one/two/three", exactPathMatcher1).addPath("/one/two", exactPathMatcher2) + .addPath("/one/two/three*", prefixPathMatcher2).addPath("/one/two/three/four", exactPathMatcher3).build(); + assertMatched(matcher, "/one/two/three", exactPathMatcher1); + assertMatched(matcher, "/one/two", exactPathMatcher2); + assertMatched(matcher, "/one/two/three/four", exactPathMatcher3); + assertMatched(matcher, "/one/two/three/fou", prefixPathMatcher2); + assertMatched(matcher, "/one/two/three/four/", prefixPathMatcher2); + assertMatched(matcher, "/one/two/three/five", prefixPathMatcher2); + assertMatched(matcher, "/one/two/three/", prefixPathMatcher2); + assertMatched(matcher, "/one/two/thre", prefixPathMatcher1); + assertMatched(matcher, "/one/two/", prefixPathMatcher1); + assertNotMatched(matcher, "/one/tw"); + assertNotMatched(matcher, "/one/"); + assertNotMatched(matcher, "/"); + assertNotMatched(matcher, ""); + } + + @Test + public void testPrefixPathDefaultHandler() { + final Object defaultHandler = new Object(); + ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/one/two*", HANDLER) + .addPath("/*", defaultHandler).addPath("/q*", HANDLER).build(); + assertMatched(matcher, "/", defaultHandler); + assertMatched(matcher, "", defaultHandler); + assertMatched(matcher, "0", defaultHandler); + assertMatched(matcher, "/q"); + assertMatched(matcher, "/q/dev-ui"); + assertMatched(matcher, "/qE", defaultHandler); + assertMatched(matcher, "/one/two"); + assertMatched(matcher, "/one/two/three"); + assertMatched(matcher, "/one/twothree", defaultHandler); + final Object exactPathMatcher1 = new Object(); + final Object exactPathMatcher2 = new Object(); + final Object exactPathMatcher3 = new Object(); + final Object prefixPathMatcher1 = new Object(); + final Object prefixPathMatcher2 = new Object(); + matcher = ImmutablePathMatcher.builder().addPath("/one/two/*", prefixPathMatcher1).addPath("/*", defaultHandler) + .addPath("/one/two/three", exactPathMatcher1).addPath("/one/two", exactPathMatcher2) + .addPath("/one/two/three*", prefixPathMatcher2).addPath("/one/two/three/four", exactPathMatcher3).build(); + assertMatched(matcher, "/one/two/three", exactPathMatcher1); + assertMatched(matcher, "/one/two", exactPathMatcher2); + assertMatched(matcher, "/one/two/three/four", exactPathMatcher3); + assertMatched(matcher, "/one/two/three/fou", prefixPathMatcher2); + assertMatched(matcher, "/one/two/three/four/", prefixPathMatcher2); + assertMatched(matcher, "/one/two/three/five", prefixPathMatcher2); + assertMatched(matcher, "/one/two/three/", prefixPathMatcher2); + assertMatched(matcher, "/one/two/thre", prefixPathMatcher1); + assertMatched(matcher, "/one/two/", prefixPathMatcher1); + assertMatched(matcher, "/one/tw", defaultHandler); + assertMatched(matcher, "/one/", defaultHandler); + assertMatched(matcher, "/", defaultHandler); + assertMatched(matcher, "", defaultHandler); + } + + @Test + public void testPrefixPathsNoDefaultHandlerNoExactPath() { + final Object handler1 = new Object(); + final Object handler2 = new Object(); + final ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/one/two*", handler1) + .addPath("/q*", handler2).build(); + assertNotMatched(matcher, "/"); + assertNotMatched(matcher, ""); + assertNotMatched(matcher, "0"); + assertMatched(matcher, "/q", handler2); + assertMatched(matcher, "/q/dev-ui", handler2); + assertNotMatched(matcher, "/qE"); + assertMatched(matcher, "/one/two", handler1); + assertMatched(matcher, "/one/two/three", handler1); + assertMatched(matcher, "/one/two/", handler1); + assertNotMatched(matcher, "/one/twothree"); + } + + @Test + public void testSpecialChars() { + // strictly speaking query params are not part of request path passed to the matcher + // but here they are treated like any other character different from path separator + final Object handler1 = new Object(); + final Object handler2 = new Object(); + final Object handler3 = new Object(); + final Object handler4 = new Object(); + final Object handler5 = new Object(); + // with default handler + ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/one/two#three", handler2) + .addPath("/one/two?three=four", handler1).addPath("/one/*/three?one\\\\\\=two", handler3) + .addPath("/one/two#three*", handler4).addPath("/*/two#three*", handler5).addPath("/*", HANDLER) + .build(); + assertMatched(matcher, "/one/two#three", handler2); + assertMatched(matcher, "/one/two?three=four", handler1); + assertMatched(matcher, "/one/any-value/three?one\\\\\\=two", handler3); + assertMatched(matcher, "/one/two/three?one\\\\\\=two", handler3); + assertMatched(matcher, "/one/two/three?one\\=two"); + assertMatched(matcher, "/one/two/three?one\\\\\\=two-three"); + assertMatched(matcher, "/one/two/three?one"); + assertMatched(matcher, "/one/two/three?"); + assertMatched(matcher, "/one/two#three?"); + assertMatched(matcher, "/one/two#thre"); + assertMatched(matcher, "/one/two"); + assertMatched(matcher, "/one/two?three=four#"); + assertMatched(matcher, "/one/two?three=fou"); + assertMatched(matcher, "/one/two#three/", handler4); + assertMatched(matcher, "/one/two#three/christmas!", handler4); + assertMatched(matcher, "/one/two#thre"); + assertMatched(matcher, "/one1/two#three", handler5); + assertMatched(matcher, "/one1/two#three/", handler5); + assertMatched(matcher, "/one1/two#three/christmas!", handler5); + assertMatched(matcher, "/one1/two#thre"); + // no default handler + matcher = ImmutablePathMatcher.builder().addPath("/one/two#three", handler2) + .addPath("/one/two?three=four", handler1).addPath("/one/*/three?one\\\\\\=two", handler3) + .addPath("/one/two#three*", handler4).addPath("/*/two#three*", handler5).build(); + assertMatched(matcher, "/one/two#three", handler2); + assertMatched(matcher, "/one/two?three=four", handler1); + assertMatched(matcher, "/one/any-value/three?one\\\\\\=two", handler3); + assertMatched(matcher, "/one/two/three?one\\\\\\=two", handler3); + assertNotMatched(matcher, "/one/two/three?one\\=two"); + assertNotMatched(matcher, "/one/two/three?one\\\\\\=two-three"); + assertNotMatched(matcher, "/one/two/three?one"); + assertNotMatched(matcher, "/one/two/three?"); + assertNotMatched(matcher, "/one/two#three?"); + assertNotMatched(matcher, "/one/two#thre"); + assertNotMatched(matcher, "/one/two"); + assertNotMatched(matcher, "/one/two?three=four#"); + assertNotMatched(matcher, "/one/two?three=fou"); + assertMatched(matcher, "/one/two#three/", handler4); + assertMatched(matcher, "/one/two#three/christmas!", handler4); + assertNotMatched(matcher, "/one/two#thre"); + assertMatched(matcher, "/one1/two#three", handler5); + assertMatched(matcher, "/one1/two#three/", handler5); + assertMatched(matcher, "/one1/two#three/christmas!", handler5); + assertNotMatched(matcher, "/one1/two#thre"); + } + + @Test + public void testInnerWildcardsWithExactMatches() { + final Object handler1 = new Object(); + final Object handler2 = new Object(); + final Object handler3 = new Object(); + final Object handler4 = new Object(); + final Object handler5 = new Object(); + final Object handler6 = new Object(); + final Object handler7 = new Object(); + final Object handler8 = new Object(); + final ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/one/two", handler1) + .addPath("/one/two/three", handler2).addPath("/one/two/three/four", handler3) + .addPath("/", handler4).addPath("/*", HANDLER).addPath("/one/two/*/four", handler5) + .addPath("/one/*/three/four", handler6).addPath("/*/two/three/four", handler7) + .addPath("/*/two", handler8).build(); + assertMatched(matcher, "/one/two", handler1); + assertMatched(matcher, "/one/two/three", handler2); + assertMatched(matcher, "/one/two/three/four", handler3); + assertMatched(matcher, "/", handler4); + assertMatched(matcher, ""); + assertMatched(matcher, "no-one-likes-us"); + assertMatched(matcher, "/one/two/we-do-not-care/four", handler5); + assertMatched(matcher, "/one/two/we-do-not-care/four/4"); + assertMatched(matcher, "/one/we-are-millwall/three/four", handler6); + assertMatched(matcher, "/1-one/we-are-millwall/three/four"); + assertMatched(matcher, "/super-millwall/two/three/four", handler7); + assertMatched(matcher, "/super-millwall/two/three/four/"); + assertMatched(matcher, "/super-millwall/two/three/four/1"); + assertMatched(matcher, "/from-the-den/two", handler8); + assertMatched(matcher, "/from-the-den/two2"); + } + + @Test + public void testInnerWildcardsOnly() { + final Object handler1 = new Object(); + final Object handler2 = new Object(); + final Object handler3 = new Object(); + final Object handler4 = new Object(); + final Object handler5 = new Object(); + // with default path handler + ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/*/two", handler2) + .addPath("/*/*/three", handler1).addPath("/one/*/three", handler3) + .addPath("/one/two/*/four", handler4).addPath("/one/two/three/*/five", handler5) + .addPath("/*", HANDLER).build(); + assertMatched(matcher, "/any-value"); + assertMatched(matcher, "/one/two/three/four/five", handler5); + assertMatched(matcher, "/one/two/three/4/five", handler5); + assertMatched(matcher, "/one/two/three/sergey/five", handler5); + assertMatched(matcher, "/one/two/three/sergey/five-ish"); + assertMatched(matcher, "/one/two/three/sergey/five/"); + assertMatched(matcher, "/one/two/three/four", handler4); + assertMatched(matcher, "/one/two/3/four", handler4); + assertMatched(matcher, "/one/two/three", handler3); + assertMatched(matcher, "/one/2/three", handler3); + assertMatched(matcher, "/one/some-very-long-text/three", handler3); + assertMatched(matcher, "/two"); + assertMatched(matcher, "/two/two", handler2); + assertMatched(matcher, "/2/two", handler2); + assertMatched(matcher, "/ho-hey/two", handler2); + assertMatched(matcher, "/ho-hey/two2"); + assertMatched(matcher, "/ho-hey/two2/"); + assertMatched(matcher, "/ho-hey/two/"); + assertMatched(matcher, "/ho-hey/hey-ho/three", handler1); + assertMatched(matcher, "/1/2/three", handler1); + assertMatched(matcher, "/1/two/three", handler1); + assertMatched(matcher, "/1/two/three/"); + assertMatched(matcher, "/1/two/three/f"); + // no default path handler + matcher = ImmutablePathMatcher.builder().addPath("/*/two", handler2) + .addPath("/*/*/three", handler1).addPath("/one/*/three", handler3) + .addPath("/one/two/*/four", handler4).addPath("/one/two/three/*/five", handler5).build(); + assertNotMatched(matcher, "/any-value"); + assertMatched(matcher, "/one/two/three/four/five", handler5); + assertMatched(matcher, "/one/two/three/4/five", handler5); + assertMatched(matcher, "/one/two/three/sergey/five", handler5); + assertNotMatched(matcher, "/one/two/three/sergey/five-ish"); + assertNotMatched(matcher, "/one/two/three/sergey/five/"); + assertMatched(matcher, "/one/two/three/four", handler4); + assertMatched(matcher, "/one/two/3/four", handler4); + assertMatched(matcher, "/one/two/three", handler3); + assertMatched(matcher, "/one/2/three", handler3); + assertMatched(matcher, "/one/some-very-long-text/three", handler3); + assertNotMatched(matcher, "/two"); + assertMatched(matcher, "/two/two", handler2); + assertMatched(matcher, "/2/two", handler2); + assertMatched(matcher, "/ho-hey/two", handler2); + assertNotMatched(matcher, "/ho-hey/two2"); + assertNotMatched(matcher, "/ho-hey/two2/"); + assertNotMatched(matcher, "/ho-hey/two/"); + assertMatched(matcher, "/ho-hey/hey-ho/three", handler1); + assertMatched(matcher, "/1/2/three", handler1); + assertMatched(matcher, "/1/two/three", handler1); + assertNotMatched(matcher, "/1/two/three/"); + assertNotMatched(matcher, "/1/two/three/f"); + } + + @Test + public void testInnerWildcardWithEndingWildcard() { + final Object handler1 = new Object(); + final Object handler2 = new Object(); + final Object handler3 = new Object(); + final Object handler4 = new Object(); + final Object handler5 = new Object(); + // with default handler + ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/*/two/*", handler1) + .addPath("/one/*/*", handler2).addPath("/one/two/*/four*", handler3) + .addPath("/one/*/three/*", handler4).addPath("/one/two/*/*", handler5) + .addPath("/*", HANDLER).build(); + assertMatched(matcher, "/one/two/three/four/five/six", handler3); + assertMatched(matcher, "/one/two/three/four/five", handler3); + assertMatched(matcher, "/one/two/three/four/", handler3); + assertMatched(matcher, "/one/two/three/four", handler3); + assertMatched(matcher, "/one/two/3/four", handler3); + assertMatched(matcher, "/one/two/three/4", handler5); + assertMatched(matcher, "/one/two/three/4/", handler5); + assertMatched(matcher, "/one/two/three/4/five", handler5); + assertMatched(matcher, "/one/2/three/four/five", handler4); + assertMatched(matcher, "/one/2/3/four/five", handler2); + assertMatched(matcher, "/1/two/three/four/five", handler1); + assertMatched(matcher, "/1/2/three/four/five"); + } + + @Test + public void testInnerWildcardsDefaultHandler() { + final Object handler1 = new Object(); + final Object handler2 = new Object(); + final Object handler3 = new Object(); + // both default root path handler and sub-path handler + ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/*/*", handler1) + .addPath("/*/*/three", handler3).addPath("/*", handler2).build(); + assertMatched(matcher, "/one/two/three", handler3); + assertMatched(matcher, "/one/two/four", handler1); + assertMatched(matcher, "/one/two", handler1); + assertMatched(matcher, "/one", handler2); + assertMatched(matcher, "/", handler2); + } + + @Test + public void testInvalidPathPattern() { + // path must start with a path separator + assertThrows(IllegalArgumentException.class, () -> ImmutablePathMatcher.builder().addPath("one", HANDLER).build()); + // inner wildcard must always be only path segment character + assertThrows(ConfigurationException.class, () -> ImmutablePathMatcher.builder().addPath("/one*/", HANDLER).build()); + assertThrows(ConfigurationException.class, () -> ImmutablePathMatcher.builder().addPath("/*one/", HANDLER).build()); + assertThrows(ConfigurationException.class, () -> ImmutablePathMatcher.builder().addPath("/o*ne/", HANDLER).build()); + assertThrows(ConfigurationException.class, () -> ImmutablePathMatcher.builder().addPath("/one/*two/", HANDLER).build()); + assertThrows(ConfigurationException.class, () -> ImmutablePathMatcher.builder().addPath("/one/*two/", HANDLER).build()); + assertThrows(ConfigurationException.class, () -> ImmutablePathMatcher.builder().addPath("/one/two*/", HANDLER).build()); + assertThrows(ConfigurationException.class, + () -> ImmutablePathMatcher.builder().addPath("/one/*two*/", HANDLER).build()); + } + + @Test + public void testExactPathHandlerMerging() { + List handler1 = new ArrayList<>(); + handler1.add("Neo"); + List handler2 = new ArrayList<>(); + handler2.add("Trinity"); + List handler3 = new ArrayList<>(); + handler3.add("Morpheus"); + var matcher = ImmutablePathMatcher.> builder().handlerAccumulator(List::addAll) + .addPath("/exact-path", handler1).addPath("/exact-path", handler2) + .addPath("/exact-not-matched", handler3).build(); + var handler = matcher.match("/exact-path").getValue(); + assertNotNull(handler); + assertTrue(handler.contains("Neo")); + assertTrue(handler.contains("Trinity")); + assertEquals(2, handler.size()); + handler = matcher.match("/exact-not-matched").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + } + + @Test + public void testPrefixPathHandlerMerging() { + List handler1 = new ArrayList<>(); + handler1.add("Neo"); + List handler2 = new ArrayList<>(); + handler2.add("Trinity"); + List handler3 = new ArrayList<>(); + handler3.add("Morpheus"); + List handler4 = new ArrayList<>(); + handler4.add("AgentSmith"); + List handler5 = new ArrayList<>(); + handler5.add("TheOracle"); + List handler6 = new ArrayList<>(); + handler6.add("AgentBrown"); + var matcher = ImmutablePathMatcher.> builder().handlerAccumulator(List::addAll).addPath("/path*", handler1) + .addPath("/path*", handler2).addPath("/path/*", handler3).addPath("/path/", handler4) + .addPath("/path/*/", handler5).addPath("/*", handler6).build(); + var handler = matcher.match("/path").getValue(); + assertNotNull(handler); + assertTrue(handler.contains("Neo")); + assertTrue(handler.contains("Trinity")); + assertTrue(handler.contains("Morpheus")); + assertEquals(3, handler.size()); + handler = matcher.match("/path/").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + assertTrue(handler.contains("AgentSmith")); + handler = matcher.match("/stuart").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + assertTrue(handler.contains("AgentBrown")); + handler = matcher.match("/path/ozzy/").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + assertTrue(handler.contains("TheOracle")); + } + + @Test + public void testInnerWildcardPathHandlerMerging() { + List handler1 = new ArrayList<>(); + handler1.add("Neo"); + List handler2 = new ArrayList<>(); + handler2.add("Trinity"); + List handler3 = new ArrayList<>(); + handler3.add("Morpheus"); + List handler4 = new ArrayList<>(); + handler4.add("AgentSmith"); + List handler5 = new ArrayList<>(); + handler5.add("TheOracle"); + List handler6 = new ArrayList<>(); + handler6.add("AgentBrown"); + List handler7 = new ArrayList<>(); + handler7.add("TheOperator"); + List handler8 = new ArrayList<>(); + handler8.add("TheSpoonBoy"); + List handler9 = new ArrayList<>(); + handler9.add("TheArchitect"); + List handler10 = new ArrayList<>(); + handler10.add("KeyMan"); + List handler11 = new ArrayList<>(); + handler11.add("Revolutions"); + List handler12 = new ArrayList<>(); + handler12.add("Reloaded-1"); + List handler13 = new ArrayList<>(); + handler13.add("Reloaded-2"); + List handler14 = new ArrayList<>(); + handler14.add("Reloaded-3"); + var matcher = ImmutablePathMatcher.> builder().handlerAccumulator(List::addAll) + .addPath("/*/one", handler1).addPath("/*/*", handler2).addPath("/*/*", handler3) + .addPath("/*/one", handler4).addPath("/*/two", handler5).addPath("/*", handler6) + .addPath("/one/*/three", handler7).addPath("/one/*", handler8).addPath("/one/*/*", handler9) + .addPath("/one/*/three", handler10).addPath("/one/*/*", handler11) + .addPath("/one/*/*/*", handler12).addPath("/one/*/*/*", handler13) + .addPath("/one/*/*/*", handler14).build(); + var handler = matcher.match("/one/two/three").getValue(); + assertNotNull(handler); + assertEquals(2, handler.size()); + assertTrue(handler.contains("TheOperator")); + assertTrue(handler.contains("KeyMan")); + handler = matcher.match("/one/two/three/four").getValue(); + assertNotNull(handler); + assertEquals(3, handler.size()); + assertTrue(handler.contains("Reloaded-1")); + assertTrue(handler.contains("Reloaded-2")); + assertTrue(handler.contains("Reloaded-3")); + handler = matcher.match("/one/2/3").getValue(); + assertNotNull(handler); + assertEquals(2, handler.size()); + assertTrue(handler.contains("TheArchitect")); + assertTrue(handler.contains("Revolutions")); + handler = matcher.match("/one/two").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + assertTrue(handler.contains("TheSpoonBoy")); + handler = matcher.match("/1/one").getValue(); + assertNotNull(handler); + assertEquals(2, handler.size()); + assertTrue(handler.contains("Neo")); + assertTrue(handler.contains("AgentSmith")); + handler = matcher.match("/1/two").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + assertTrue(handler.contains("TheOracle")); + handler = matcher.match("/father-brown").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + assertTrue(handler.contains("AgentBrown")); + handler = matcher.match("/welcome/to/the/jungle").getValue(); + assertNotNull(handler); + assertEquals(2, handler.size()); + assertTrue(handler.contains("Trinity")); + assertTrue(handler.contains("Morpheus")); + } + + @Test + public void testDefaultHandlerInnerWildcardAndEndingWildcard() { + // calling it default handler inner wildcard because first '/' path is matched and then '/one*' + // '/one*' is matched as prefix path + final ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/*/one*", HANDLER).build(); + assertMatched(matcher, "/1/one"); + assertMatched(matcher, "/2/one"); + assertMatched(matcher, "/3/one"); + assertMatched(matcher, "/4/one"); + assertMatched(matcher, "/4/one"); + assertMatched(matcher, "/1/one/"); + assertMatched(matcher, "/1/one/two"); + assertNotMatched(matcher, "/"); + assertNotMatched(matcher, "/1"); + assertNotMatched(matcher, "/1/"); + assertNotMatched(matcher, "/1/one1"); + assertNotMatched(matcher, "/1/two"); + assertNotMatched(matcher, "/1/on"); + } + + @Test + public void testDefaultHandlerOneInnerWildcard() { + // calling it default handler inner wildcard because first '/' path is matched and then '/one' + // '/one' is matched as exact path + final ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/*/one", HANDLER).build(); + assertMatched(matcher, "/1/one"); + assertMatched(matcher, "/2/one"); + assertMatched(matcher, "/3/one"); + assertMatched(matcher, "/4/one"); + assertMatched(matcher, "/4/one"); + assertNotMatched(matcher, "/"); + assertNotMatched(matcher, "/1"); + assertNotMatched(matcher, "/1/"); + assertNotMatched(matcher, "/1/two"); + assertNotMatched(matcher, "/1/one/"); + assertNotMatched(matcher, "/1/one1"); + assertNotMatched(matcher, "/1/on"); + assertNotMatched(matcher, "/1/one/two"); + } + + private static void assertMatched(ImmutablePathMatcher matcher, String path, Object handler) { + var match = matcher.match(path); + assertEquals(handler, match.getValue()); + } + + private static void assertMatched(ImmutablePathMatcher matcher, String path) { + assertMatched(matcher, path, HANDLER); + } + + private static void assertNotMatched(ImmutablePathMatcher matcher, String path) { + var match = matcher.match(path); + assertNull(match.getValue()); + } + +}