diff --git a/.github/native-tests.json b/.github/native-tests.json index 87547f9ca469d..cd9b3beb494a9 100644 --- a/.github/native-tests.json +++ b/.github/native-tests.json @@ -99,7 +99,7 @@ { "category": "Misc1", "timeout": 70, - "test-modules": "maven, jackson, jsonb, kotlin-serialization, rest-client-reactive-kotlin-serialization, quartz, qute, logging-min-level-unset, logging-min-level-set, simple with space", + "test-modules": "maven, jackson, jsonb, kotlin, rest-client-reactive-kotlin-serialization, quartz, qute, logging-min-level-unset, logging-min-level-set, simple with space", "os-name": "ubuntu-latest" }, { diff --git a/.github/quarkus-github-lottery.yml b/.github/quarkus-github-lottery.yml index e8f8e098d1896..3b59421c5b20b 100644 --- a/.github/quarkus-github-lottery.yml +++ b/.github/quarkus-github-lottery.yml @@ -7,6 +7,10 @@ buckets: delay: PT0S timeout: P3D maintenance: + created: + delay: PT0S + timeout: P1D + expiry: P14D feedback: labels: ["triage/needs-reproducer", "triage/needs-feedback"] needed: @@ -30,18 +34,22 @@ participants: maintenance: labels: ["area/hibernate-orm", "area/hibernate-search", "area/elasticsearch", "area/jdbc"] days: ["WEDNESDAY"] + created: + maxIssues: 3 feedback: needed: - maxIssues: 10 + maxIssues: 3 provided: - maxIssues: 10 + maxIssues: 3 stale: - maxIssues: 5 + maxIssues: 1 - username: "marko-bekhta" timezone: "Europe/Warsaw" maintenance: labels: ["area/hibernate-search", "area/elasticsearch", "area/hibernate-validator"] days: ["WEDNESDAY"] + created: + maxIssues: 3 feedback: needed: maxIssues: 10 @@ -57,6 +65,8 @@ participants: maintenance: labels: ["area/hibernate-validator", "area/jakarta"] days: ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"] + created: + maxIssues: 2 feedback: needed: maxIssues: 4 @@ -120,6 +130,8 @@ participants: maintenance: labels: ["area/core", "area/testing", "area/kotlin", "area/spring", "area/rest", "area/kubernetes"] days: ["WEDNESDAY", "FRIDAY"] + created: + maxIssues: 2 feedback: needed: maxIssues: 4 diff --git a/.github/workflows/ci-actions-incremental.yml b/.github/workflows/ci-actions-incremental.yml index df2ab3edf07bd..ab05cfdb4c270 100644 --- a/.github/workflows/ci-actions-incremental.yml +++ b/.github/workflows/ci-actions-incremental.yml @@ -315,13 +315,13 @@ jobs: elif [ "${GIB_IMPACTED_MODULES}" != '_all_' ] then # Important: keep -pl ... in actual jobs in sync with the following grep commands! - if ! echo -n "${GIB_IMPACTED_MODULES}" | grep -qPv 'integration-tests/(devtools|gradle|maven|devmode|kubernetes/.*)|tcks/.*'; then run_jvm=false; fi - if ! echo -n "${GIB_IMPACTED_MODULES}" | grep -q 'integration-tests/devtools'; then run_devtools=false; fi - if ! echo -n "${GIB_IMPACTED_MODULES}" | grep -q 'integration-tests/gradle'; then run_gradle=false; fi - if ! echo -n "${GIB_IMPACTED_MODULES}" | grep -qP 'integration-tests/(maven|devmode)'; then run_maven=false; fi - if ! echo -n "${GIB_IMPACTED_MODULES}" | grep -qP 'integration-tests/kubernetes/.*'; then run_kubernetes=false; fi - if ! echo -n "${GIB_IMPACTED_MODULES}" | grep -qPv '(docs|integration-tests|tcks)/.*'; then run_quickstarts=false; fi - if ! echo -n "${GIB_IMPACTED_MODULES}" | grep -q 'tcks/.*'; then run_tcks=false; fi + if ! (echo -n "${GIB_IMPACTED_MODULES}" | grep -qPv 'integration-tests/(devtools|gradle|maven|devmode|kubernetes/.*)|tcks/.*'); then run_jvm=false; fi + if ! (echo -n "${GIB_IMPACTED_MODULES}" | grep -q 'integration-tests/devtools'); then run_devtools=false; fi + if ! (echo -n "${GIB_IMPACTED_MODULES}" | grep -q 'integration-tests/gradle'); then run_gradle=false; fi + if ! (echo -n "${GIB_IMPACTED_MODULES}" | grep -qP 'integration-tests/(maven|devmode)'); then run_maven=false; fi + if ! (echo -n "${GIB_IMPACTED_MODULES}" | grep -qP 'integration-tests/kubernetes/.*'); then run_kubernetes=false; fi + if ! (echo -n "${GIB_IMPACTED_MODULES}" | grep -qPv '(docs|integration-tests|tcks)/.*'); then run_quickstarts=false; fi + if ! (echo -n "${GIB_IMPACTED_MODULES}" | grep -q 'tcks/.*'); then run_tcks=false; fi fi echo "run_jvm=${run_jvm}, run_devtools=${run_devtools}, run_gradle=${run_gradle}, run_maven=${run_maven}, run_kubernetes=${run_kubernetes}, run_quickstarts=${run_quickstarts}, run_tcks=${run_tcks}" echo "run_jvm=${run_jvm}" >> $GITHUB_OUTPUT diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index e512e56a78585..9c438a9c1d96d 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -2,7 +2,7 @@ com.gradle develocity-maven-extension - 1.22.2 + 1.23 com.gradle diff --git a/MAINTAINERS.adoc b/MAINTAINERS.adoc index fa8a42f66b7e7..49d99406a5265 100644 --- a/MAINTAINERS.adoc +++ b/MAINTAINERS.adoc @@ -100,9 +100,6 @@ If you think some information is outdated, either provide a pull request or emai |Jackson |https://github.com/geoand[Georgios Andrianakis], https://github.com/gsmet[Guillaume Smet] -|Jaeger -|https://github.com/objectiser[Gary Brown] - |JAXB |https://github.com/gsmet[Guillaume Smet] @@ -121,12 +118,6 @@ If you think some information is outdated, either provide a pull request or emai |JDBC - SQL Server |https://github.com/Sanne[Sanne Grinovero] -|JGit -|https://github.com/gastaldi[George Gastaldi] - -|JSch -|https://github.com/gastaldi[George Gastaldi] - |JSON-B |https://github.com/geoand[Georgios Andrianakis], https://github.com/gsmet[Guillaume Smet] diff --git a/README.md b/README.md index bd2037b525683..663a5c0283b13 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ [![Project Chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg?style=for-the-badge&logo=zulip)](https://quarkusio.zulipchat.com/) [![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?style=for-the-badge&logo=gitpod&logoColor=white)](https://gitpod.io/#https://github.com/quarkusio/quarkus/-/tree/main/) [![Supported JVM Versions](https://img.shields.io/badge/JVM-17--21-brightgreen.svg?style=for-the-badge&logo=openjdk)](https://github.com/quarkusio/quarkus/actions/runs/113853915/) -[![Develocity](https://img.shields.io/badge/Revved%20up%20by-Develocity-06A0CE?style=for-the-badge&logo=gradle)](https://ge.quarkus.io/scans) +[![Develocity](https://img.shields.io/badge/Revved%20up%20by-Develocity-007EC5?style=for-the-badge&logo=gradle)](https://ge.quarkus.io/scans) [![GitHub Repo stars](https://img.shields.io/github/stars/quarkusio/quarkus?style=for-the-badge)](https://github.com/quarkusio/quarkus/stargazers) +[![Gurubase](https://img.shields.io/badge/Gurubase-Ask%20Quarkus%20Guru-007EC5?style=for-the-badge)](https://gurubase.io/g/quarkus) # Quarkus - Supersonic Subatomic Java diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index b1f09a9da9249..fa0dd3f42dde1 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -133,6 +133,31 @@ Stop the application with `CTRL+C` once you have gathered what you want (e.g. ju It will dump the profiling information. The name of the file is in the `file=` parameter. +> [!TIP] +> You can avoid needing to stop the application manually by writing a custom main that stops the application once it boots up +> +> ```java +> import io.quarkus.runtime.Quarkus; +> import io.quarkus.runtime.QuarkusApplication; +> import io.quarkus.runtime.annotations.QuarkusMain; +> +> @QuarkusMain +> public class Main { +> +> public static void main(String... args) { +> Quarkus.run(MyApp.class, args); +> } +> +> public static class MyApp implements QuarkusApplication { +> +> @Override +> public int run(String... args) throws Exception { +> return 0; +> } +> } +> } +``` + For the allocation case, you obtain a JFR file, you can convert it to your typical Async Profiler flamegraph HTML output with: ```shell script diff --git a/bom/application/pom.xml b/bom/application/pom.xml index fb54502ae14b7..5a8aebeed6654 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -27,12 +27,12 @@ 1 1.1.7 3.0.0.Final - 3.1.3.Final + 3.2.0.Final 6.2.11.Final 2.8.0-alpha 1.27.0-alpha - 5.3.3 - 1.14.1 + 5.3.4 + 1.14.2 2.2.2 0.22.0 22.2 @@ -46,19 +46,19 @@ 2.1 2.0 4.0.2 - 2.8.0 + 2.9.0 3.10.2 4.1.0 4.0.0 - 4.0.3 + 4.0.5 2.11.0 - 6.7.0 + 6.7.1 4.6.1 2.1.2 1.0.13 3.0.1 3.17.1 - 4.25.0 + 4.26.0 2.7.0 2.1.3 3.0.0 @@ -99,7 +99,7 @@ 0.0.9.Final 2.5 8.0.0.Final - 8.16.1 + 8.17.0 2.2.21 2.2.5.Final 2.2.2.SP01 @@ -119,7 +119,7 @@ 2.3.230 42.7.4 - 3.5.1 + 3.4.1 8.3.0 12.8.1.jre11 1.6.7 @@ -138,7 +138,7 @@ 3.6.1.Final 2.7.0 4.0.5 - 3.7.1 + 3.7.2 1.8.0 1.1.10.5 0.107.0 @@ -157,8 +157,8 @@ 4.1.4 3.2.0 4.2.2 - 3.1.0.Final - 11.0.0 + 3.1.1.Final + 11.1.0 3.0.4 4.29.1 @@ -167,7 +167,7 @@ 6.0.0 5.2.1 0.34.1 - 3.26.2 + 3.26.3 0.3.0 4.18.1 6.1.SP4 @@ -182,18 +182,18 @@ 3.48.3 2.36.0 0.27.2 - 1.45.1 + 1.45.3 2.1 4.7.6 1.1.4 1.27.1 - 1.12.0 + 1.13.0 2.11.0 2.0.1.Final - 2.24.2 + 2.24.3 1.3.1.Final 1.12.0 - 2.6.5.Final + 2.6.6.Final 0.1.18.Final 1.20.4 3.4.0 @@ -203,7 +203,7 @@ 2.8.2 2.6 2.4.0 - 7.0.0.202409031743-r + 7.1.0.202411261347-r 0.15.0 9.47 @@ -214,11 +214,12 @@ 0.8.11 1.1.0 3.3.0 - 2.12.3 + 2.12.4 0.16.0 1.0.11 + 0.28.0.RELEASE @@ -723,6 +724,11 @@ quarkus-agroal ${project.version} + + io.quarkus + quarkus-agroal-dev + ${project.version} + io.quarkus quarkus-agroal-deployment @@ -3284,7 +3290,7 @@ io.quarkus - quarkus-junit5-properties + quarkus-junit5-config ${project.version} @@ -6509,6 +6515,18 @@ ${project.version} + + + com.webauthn4j + webauthn4j-core-async + ${webauthn4j.version} + + + com.webauthn4j + webauthn4j-metadata-async + ${webauthn4j.version} + + io.quarkus @@ -6532,6 +6550,7 @@ ${project.version} + diff --git a/build-parent/pom.xml b/build-parent/pom.xml index 332c47bfc7e08..dfbc9cd07aa5c 100644 --- a/build-parent/pom.xml +++ b/build-parent/pom.xml @@ -21,7 +21,7 @@ ${version.compiler.plugin} 2.0.21 - 1.9.20 + 2.0.0 2.13.12 4.9.2 @@ -34,7 +34,7 @@ 2.5.13 4.7.0 - 3.26.2 + 3.26.3 2.0.3.Final 6.0.1 @@ -101,9 +101,9 @@ quay.io/keycloak/keycloak:${keycloak.version} quay.io/keycloak/keycloak:${keycloak.wildfly.version}-legacy - 7.0.1 + 7.0.2 - 3.26.3 + 3.27.0 3.10.0 7.3.0 @@ -116,7 +116,7 @@ 2.0.0 0.45.1 - 3.8.1 + 3.9.0 0.14.7 @@ -660,6 +660,11 @@ + + io.smallrye.certs + smallrye-certificate-generator-maven-plugin + ${smallrye-certificate-generator.version} + diff --git a/core/deployment/src/main/java/io/quarkus/deployment/IsLocalDevelopment.java b/core/deployment/src/main/java/io/quarkus/deployment/IsLocalDevelopment.java new file mode 100644 index 0000000000000..626b76a2af00a --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/IsLocalDevelopment.java @@ -0,0 +1,27 @@ +package io.quarkus.deployment; + +import java.util.function.BooleanSupplier; + +import io.quarkus.dev.spi.DevModeType; +import io.quarkus.runtime.LaunchMode; + +/** + * Similar to {@link IsDevelopment} except checks whether the application is being launched in dev mode but not from a + * {@code mutable-jar} package, + * in other words, not a remote server in a remote dev session. + */ +public class IsLocalDevelopment implements BooleanSupplier { + + private final LaunchMode launchMode; + private final DevModeType devModeType; + + public IsLocalDevelopment(LaunchMode launchMode, DevModeType devModeType) { + this.launchMode = launchMode; + this.devModeType = devModeType; + } + + @Override + public boolean getAsBoolean() { + return launchMode == LaunchMode.DEVELOPMENT && devModeType != DevModeType.REMOTE_SERVER_SIDE; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/DevServicesSharedNetworkBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/DevServicesSharedNetworkBuildItem.java index df98eb89db333..e6a60b50116c6 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/builditem/DevServicesSharedNetworkBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/DevServicesSharedNetworkBuildItem.java @@ -10,6 +10,7 @@ import io.quarkus.builder.BuildStepBuilder; import io.quarkus.builder.item.MultiBuildItem; import io.quarkus.deployment.dev.devservices.DevServicesConfig; +import io.quarkus.deployment.dev.devservices.GlobalDevServicesConfig; /** * A marker build item that indicates, if any instances are provided during the build, the containers started by DevServices @@ -81,4 +82,17 @@ public static boolean isSharedNetworkRequired( (!devServicesSharedNetworkBuildItem.isEmpty() && devServicesSharedNetworkBuildItem.get(0).getSource().equals("io.quarkus.test.junit")); } + + /** + * @deprecated Please, use {@link DevServicesSharedNetworkBuildItem#isSharedNetworkRequired(DevServicesConfig, List)} + * instead. + */ + @Deprecated(forRemoval = true, since = "3.18") + public static boolean isSharedNetworkRequired( + GlobalDevServicesConfig globalDevServicesConfig, + List devServicesSharedNetworkBuildItem) { + return globalDevServicesConfig.launchOnSharedNetwork || + (!devServicesSharedNetworkBuildItem.isEmpty() + && devServicesSharedNetworkBuildItem.get(0).getSource().equals("io.quarkus.test.junit")); + } } diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/devservices/ContainerInfo.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/devservices/ContainerInfo.java index 9227f0b1a06e1..12594375ee6f0 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/devservices/ContainerInfo.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/devservices/ContainerInfo.java @@ -100,7 +100,7 @@ public String formatPorts() { return Arrays.stream(getExposedPorts()) .filter(p -> p.getPublicPort() != null) .map(c -> c.getIp() + ":" + c.getPublicPort() + "->" + c.getPrivatePort() + "/" + c.getType()) - .collect(Collectors.joining(" ,")); + .collect(Collectors.joining(", ")); } public static class ContainerPort { diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/devservices/GlobalDevServicesConfig.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/devservices/GlobalDevServicesConfig.java index 5f8d619e0b74c..593b8f3514f14 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/devservices/GlobalDevServicesConfig.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/devservices/GlobalDevServicesConfig.java @@ -12,7 +12,7 @@ * * @deprecated Please, use {@link DevServicesConfig} instead. */ -@Deprecated(forRemoval = true) +@Deprecated(forRemoval = true, since = "3.18") @ConfigRoot(name = "devservices") public class GlobalDevServicesConfig { diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/testing/TestConfig.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/testing/TestConfig.java index da1a31e3f43d9..f93894a7a1e72 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/testing/TestConfig.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/testing/TestConfig.java @@ -8,7 +8,9 @@ import io.quarkus.runtime.annotations.ConfigDocMapKey; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; +import io.quarkus.runtime.configuration.TrimmedStringConverter; import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithConverter; import io.smallrye.config.WithDefault; import io.smallrye.config.WithParentName; @@ -45,6 +47,16 @@ public interface TestConfig { @WithDefault("false") boolean displayTestOutput(); + /** + * The FQCN of the JUnit ClassOrderer to use. If the class cannot be found, it fallbacks to JUnit + * default behaviour which does not set a ClassOrderer at all. + * + * @see JUnit Class + * Order + */ + @WithDefault("io.quarkus.test.junit.util.QuarkusTestProfileAwareClassOrderer") + Optional classOrderer(); + /** * Tags that should be included for continuous testing. This supports JUnit Tag Expressions. * @@ -77,7 +89,6 @@ public interface TestConfig { * is matched against the test class name (not the file name). *

* This is ignored if include-pattern has been set. - * */ @WithDefault(".*\\.IT[^.]+|.*IT|.*ITCase") Optional excludePattern(); @@ -174,7 +185,7 @@ public interface TestConfig { * When the artifact is a {@code container}, this string is passed right after the {@code docker run} command. * When the artifact is a {@code native binary}, this string is passed right after the native binary name. */ - Optional> argLine(); + Optional<@WithConverter(TrimmedStringConverter.class) List> argLine(); /** * Additional environment variables to be set in the process that {@code @QuarkusIntegrationTest} launches. @@ -241,7 +252,6 @@ public interface TestConfig { * is matched against the module groupId:artifactId. *

* This is ignored if include-module-pattern has been set. - * */ Optional excludeModulePattern(); @@ -265,7 +275,7 @@ interface Profile { * then Quarkus will only execute tests that are annotated with a {@code @TestProfile} that has at least one of the * supplied (via the aforementioned system property) tags. */ - Optional> tags(); + Optional> tags(); } interface Container { diff --git a/core/deployment/src/main/java/io/quarkus/deployment/pkg/builditem/CompiledJavaVersionBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/pkg/builditem/CompiledJavaVersionBuildItem.java index 33d544f1fadd4..77f79cb7f3298 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/pkg/builditem/CompiledJavaVersionBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/pkg/builditem/CompiledJavaVersionBuildItem.java @@ -24,15 +24,6 @@ public JavaVersion getJavaVersion() { public interface JavaVersion { - @Deprecated(forRemoval = true) - Status isExactlyJava11(); - - @Deprecated(forRemoval = true) - Status isJava11OrHigher(); - - @Deprecated(forRemoval = true) - Status isJava17OrHigher(); - Status isJava21OrHigher(); Status isJava19OrHigher(); @@ -48,21 +39,6 @@ final class Unknown implements JavaVersion { Unknown() { } - @Override - public Status isExactlyJava11() { - return Status.UNKNOWN; - } - - @Override - public Status isJava11OrHigher() { - return Status.UNKNOWN; - } - - @Override - public Status isJava17OrHigher() { - return Status.UNKNOWN; - } - @Override public Status isJava21OrHigher() { return Status.UNKNOWN; @@ -76,8 +52,6 @@ public Status isJava19OrHigher() { final class Known implements JavaVersion { - private static final int JAVA_11_MAJOR = 55; - private static final int JAVA_17_MAJOR = 61; private static final int JAVA_19_MAJOR = 63; private static final int JAVA_21_MAJOR = 65; @@ -87,21 +61,6 @@ final class Known implements JavaVersion { this.determinedMajor = determinedMajor; } - @Override - public Status isExactlyJava11() { - return equalStatus(JAVA_11_MAJOR); - } - - @Override - public Status isJava11OrHigher() { - return higherOrEqualStatus(JAVA_11_MAJOR); - } - - @Override - public Status isJava17OrHigher() { - return higherOrEqualStatus(JAVA_17_MAJOR); - } - @Override public Status isJava19OrHigher() { return higherOrEqualStatus(JAVA_19_MAJOR); diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java index 2a9ce52ff6a43..6d87ce82fa6f1 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java @@ -27,12 +27,19 @@ import java.util.Optional; import java.util.Set; +import jakarta.annotation.Priority; + import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.ConfigProvider; import org.eclipse.microprofile.config.ConfigValue; import org.eclipse.microprofile.config.spi.ConfigSource; import org.eclipse.microprofile.config.spi.ConfigSourceProvider; import org.eclipse.microprofile.config.spi.Converter; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.ParameterizedType; +import org.jboss.jandex.Type; import org.objectweb.asm.Opcodes; import io.quarkus.bootstrap.classloading.QuarkusClassLoader; @@ -200,6 +207,7 @@ void generateMappings( @BuildStep void generateBuilders( ConfigurationBuildItem configItem, + CombinedIndexBuildItem combinedIndex, List configMappings, List runTimeDefaults, List staticInitConfigBuilders, @@ -246,6 +254,7 @@ void generateBuilders( staticCustomizers.add(StaticInitConfigBuilder.class.getName()); generateConfigBuilder(generatedClass, reflectiveClass, CONFIG_STATIC_NAME, + combinedIndex, defaultValues, converters, interceptors, @@ -269,6 +278,7 @@ void generateBuilders( runtimeCustomizers.add(RuntimeConfigBuilder.class.getName()); generateConfigBuilder(generatedClass, reflectiveClass, CONFIG_RUNTIME_NAME, + combinedIndex, defaultValues, converters, interceptors, @@ -520,7 +530,7 @@ private static String getPathWithoutExtension(Path path) { "withDefaultValue", void.class, SmallRyeConfigBuilder.class, String.class, String.class); private static final MethodDescriptor WITH_CONVERTER = MethodDescriptor.ofMethod(AbstractConfigBuilder.class, - "withConverter", void.class, SmallRyeConfigBuilder.class, Converter.class); + "withConverter", void.class, SmallRyeConfigBuilder.class, String.class, int.class, Converter.class); private static final MethodDescriptor WITH_INTERCEPTOR = MethodDescriptor.ofMethod(AbstractConfigBuilder.class, "withInterceptor", void.class, SmallRyeConfigBuilder.class, ConfigSourceInterceptor.class); @@ -549,17 +559,15 @@ private static String getPathWithoutExtension(Path path) { private static final MethodDescriptor WITH_BUILDER = MethodDescriptor.ofMethod(AbstractConfigBuilder.class, "withBuilder", void.class, SmallRyeConfigBuilder.class, ConfigBuilder.class); - private static final MethodDescriptor WITH_NAMES = MethodDescriptor.ofMethod(SmallRyeConfigBuilder.class, - "withMappingNames", - SmallRyeConfigBuilder.class, Map.class); - private static final MethodDescriptor WITH_KEYS = MethodDescriptor.ofMethod(SmallRyeConfigBuilder.class, - "withMappingKeys", - SmallRyeConfigBuilder.class, Set.class); + + private static final DotName CONVERTER_NAME = DotName.createSimple(Converter.class.getName()); + private static final DotName PRIORITY_NAME = DotName.createSimple(Priority.class.getName()); private static void generateConfigBuilder( BuildProducer generatedClass, BuildProducer reflectiveClass, String className, + CombinedIndexBuildItem combinedIndex, Map defaultValues, Set converters, Set interceptors, @@ -591,7 +599,13 @@ private static void generateConfigBuilder( } for (String converter : converters) { + ClassInfo converterClass = combinedIndex.getComputingIndex().getClassByName(converter); + Type type = getConverterType(converterClass, combinedIndex); + AnnotationInstance priorityAnnotation = converterClass.annotation(PRIORITY_NAME); + int priority = priorityAnnotation != null ? priorityAnnotation.value().asInt() : 100; method.invokeStaticMethod(WITH_CONVERTER, configBuilder, + method.load(type.name().toString()), + method.load(priority), method.newInstance(MethodDescriptor.ofConstructor(converter))); } @@ -716,4 +730,27 @@ private static Set runtimeConfigMappings(List arguments = parameterizedType.arguments(); + if (arguments.size() != 1) { + throw new IllegalArgumentException( + "Converter " + converter.name() + " must be parameterized with a single type"); + } + return arguments.get(0); + } + } + } + + return getConverterType(combinedIndex.getComputingIndex().getClassByName(converter.superName()), combinedIndex); + } } diff --git a/core/deployment/src/test/java/io/quarkus/deployment/runnerjar/OptionalDepsTest.java b/core/deployment/src/test/java/io/quarkus/deployment/runnerjar/OptionalDepsTest.java index 949b145da1b7b..2d64701a4593c 100644 --- a/core/deployment/src/test/java/io/quarkus/deployment/runnerjar/OptionalDepsTest.java +++ b/core/deployment/src/test/java/io/quarkus/deployment/runnerjar/OptionalDepsTest.java @@ -5,14 +5,16 @@ import java.util.HashSet; import java.util.Set; +import org.eclipse.aether.util.artifact.JavaScopes; + import io.quarkus.bootstrap.model.ApplicationModel; import io.quarkus.bootstrap.resolver.TsArtifact; import io.quarkus.bootstrap.resolver.TsDependency; import io.quarkus.bootstrap.resolver.TsQuarkusExt; +import io.quarkus.maven.dependency.ArtifactCoords; import io.quarkus.maven.dependency.ArtifactDependency; import io.quarkus.maven.dependency.Dependency; import io.quarkus.maven.dependency.DependencyFlags; -import io.quarkus.maven.dependency.GACTV; public class OptionalDepsTest extends BootstrapFromOriginalJarTestBase { @@ -70,17 +72,73 @@ protected TsArtifact composeApplication() { @Override protected void assertAppModel(ApplicationModel model) throws Exception { final Set expected = new HashSet<>(); - expected.add(new ArtifactDependency(new GACTV("io.quarkus.bootstrap.test", "ext-a-deployment", "1"), "compile", + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-a", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, + DependencyFlags.DIRECT, + DependencyFlags.OPTIONAL, + DependencyFlags.RUNTIME_EXTENSION_ARTIFACT, + DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT, + DependencyFlags.RUNTIME_CP, + DependencyFlags.DEPLOYMENT_CP)); + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-a-dep", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, + DependencyFlags.OPTIONAL, + DependencyFlags.RUNTIME_CP, + DependencyFlags.DEPLOYMENT_CP)); + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-a-deployment", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, + DependencyFlags.OPTIONAL, + DependencyFlags.DEPLOYMENT_CP)); + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "app-optional-dep", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, DependencyFlags.OPTIONAL, + DependencyFlags.DIRECT, + DependencyFlags.RUNTIME_CP, DependencyFlags.DEPLOYMENT_CP)); - expected.add(new ArtifactDependency(new GACTV("io.quarkus.bootstrap.test", "ext-b-deployment-dep", "1"), "compile", + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-b", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, + DependencyFlags.OPTIONAL, + DependencyFlags.RUNTIME_EXTENSION_ARTIFACT, + DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT, + DependencyFlags.RUNTIME_CP, + DependencyFlags.DEPLOYMENT_CP)); + + expected.add(new ArtifactDependency(ArtifactCoords.jar( + TsArtifact.DEFAULT_GROUP_ID, "ext-b-deployment", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, DependencyFlags.OPTIONAL, DependencyFlags.DEPLOYMENT_CP)); - expected.add(new ArtifactDependency(new GACTV("io.quarkus.bootstrap.test", "ext-b-deployment", "1"), "compile", + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-b-deployment-dep", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, DependencyFlags.OPTIONAL, DependencyFlags.DEPLOYMENT_CP)); - expected.add(new ArtifactDependency(new GACTV("io.quarkus.bootstrap.test", "ext-d-deployment", "1"), "compile", + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-d", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, + DependencyFlags.DIRECT, + DependencyFlags.RUNTIME_EXTENSION_ARTIFACT, + DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT, + DependencyFlags.RUNTIME_CP, DependencyFlags.DEPLOYMENT_CP)); - assertEquals(expected, getDeploymentOnlyDeps(model)); + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-d-deployment", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, + DependencyFlags.DEPLOYMENT_CP)); + + assertEquals(expected, getDependenciesWithFlag(model, DependencyFlags.DEPLOYMENT_CP)); } } diff --git a/core/processor/src/main/java/io/quarkus/annotation/processor/documentation/config/scanner/AbstractConfigListener.java b/core/processor/src/main/java/io/quarkus/annotation/processor/documentation/config/scanner/AbstractConfigListener.java index 4eb92d7e84277..6e99c7e9b3be1 100644 --- a/core/processor/src/main/java/io/quarkus/annotation/processor/documentation/config/scanner/AbstractConfigListener.java +++ b/core/processor/src/main/java/io/quarkus/annotation/processor/documentation/config/scanner/AbstractConfigListener.java @@ -14,6 +14,8 @@ import io.quarkus.annotation.processor.documentation.config.discovery.EnumDefinition; import io.quarkus.annotation.processor.documentation.config.discovery.EnumDefinition.EnumConstant; import io.quarkus.annotation.processor.documentation.config.discovery.ResolvedType; +import io.quarkus.annotation.processor.documentation.config.model.ConfigPhase; +import io.quarkus.annotation.processor.documentation.config.model.ExtensionModule; import io.quarkus.annotation.processor.documentation.config.util.Types; import io.quarkus.annotation.processor.util.Config; import io.quarkus.annotation.processor.util.Utils; @@ -66,6 +68,18 @@ public void onResolvedEnum(TypeElement enumTypeElement) { configCollector.addResolvedEnum(enumDefinition); } + protected void validateRuntimeConfigOnDeploymentModules(ConfigPhase configPhase, TypeElement configRoot) { + if (configPhase.equals(ConfigPhase.RUN_TIME) || configPhase.equals(ConfigPhase.BUILD_AND_RUN_TIME_FIXED)) { + ExtensionModule.ExtensionModuleType type = config.getExtensionModule().type(); + if (type.equals(ExtensionModule.ExtensionModuleType.DEPLOYMENT)) { + throw new IllegalStateException(String.format( + "Error on %s: Configuration classes with ConfigPhase.RUN_TIME or " + + "ConfigPhase.BUILD_AND_RUNTIME_FIXED phases, must reside in the respective module.", + configRoot.getSimpleName().toString())); + } + } + } + protected void handleCommonPropertyAnnotations(DiscoveryConfigProperty.Builder builder, Map propertyAnnotations, ResolvedType resolvedType, String sourceElementName) { diff --git a/core/processor/src/main/java/io/quarkus/annotation/processor/documentation/config/scanner/ConfigMappingListener.java b/core/processor/src/main/java/io/quarkus/annotation/processor/documentation/config/scanner/ConfigMappingListener.java index cda99142c602e..ea4135f7adfe3 100644 --- a/core/processor/src/main/java/io/quarkus/annotation/processor/documentation/config/scanner/ConfigMappingListener.java +++ b/core/processor/src/main/java/io/quarkus/annotation/processor/documentation/config/scanner/ConfigMappingListener.java @@ -75,6 +75,8 @@ public Optional onConfigRoot(TypeElement configRoot) { } } + validateRuntimeConfigOnDeploymentModules(configPhase, configRoot); + for (Map.Entry entry : configMappingAnnotion.getElementValues() .entrySet()) { if ("prefix()".equals(entry.getKey().toString())) { diff --git a/core/processor/src/main/java/io/quarkus/annotation/processor/documentation/config/scanner/LegacyConfigRootListener.java b/core/processor/src/main/java/io/quarkus/annotation/processor/documentation/config/scanner/LegacyConfigRootListener.java index 2410b8c62b9d6..f675f902fc9fe 100644 --- a/core/processor/src/main/java/io/quarkus/annotation/processor/documentation/config/scanner/LegacyConfigRootListener.java +++ b/core/processor/src/main/java/io/quarkus/annotation/processor/documentation/config/scanner/LegacyConfigRootListener.java @@ -79,6 +79,8 @@ public Optional onConfigRoot(TypeElement configRoot) { } } + validateRuntimeConfigOnDeploymentModules(configPhase, configRoot); + String overriddenDocPrefix = null; if (configDocPrefixAnnotation != null) { for (Map.Entry entry : configDocPrefixAnnotation diff --git a/core/runtime/src/main/java/io/quarkus/runtime/configuration/AbstractConfigBuilder.java b/core/runtime/src/main/java/io/quarkus/runtime/configuration/AbstractConfigBuilder.java index 84f9336c4ad61..15dc4e2604aa2 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/configuration/AbstractConfigBuilder.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/configuration/AbstractConfigBuilder.java @@ -21,10 +21,14 @@ protected static void withDefaultValue(SmallRyeConfigBuilder builder, String nam builder.withDefaultValue(name, value); } - // TODO - radcortez - Can be improved by avoiding introspection work in the Converter class. - // Not a big issue, because registering Converters via ServiceLoader is not a common case - protected static void withConverter(SmallRyeConfigBuilder builder, Converter converter) { - builder.withConverters(new Converter[] { converter }); + @SuppressWarnings("unchecked") + protected static void withConverter(SmallRyeConfigBuilder builder, String type, int priority, Converter converter) { + try { + // To support converters that are not public + builder.withConverter((Class) builder.getClassLoader().loadClass(type), priority, converter); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } } protected static void withInterceptor(SmallRyeConfigBuilder builder, ConfigSourceInterceptor interceptor) { diff --git a/core/runtime/src/main/java/io/quarkus/runtime/configuration/QuarkusConfigFactory.java b/core/runtime/src/main/java/io/quarkus/runtime/configuration/QuarkusConfigFactory.java index 2adc409fab816..85f778217b946 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/configuration/QuarkusConfigFactory.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/configuration/QuarkusConfigFactory.java @@ -1,5 +1,7 @@ package io.quarkus.runtime.configuration; +import org.eclipse.microprofile.config.spi.ConfigProviderResolver; + import io.quarkus.runtime.LaunchMode; import io.smallrye.config.SmallRyeConfig; import io.smallrye.config.SmallRyeConfigFactory; @@ -12,13 +14,6 @@ public final class QuarkusConfigFactory extends SmallRyeConfigFactory { private static volatile SmallRyeConfig config; - /** - * Construct a new instance. Called by service loader. - */ - public QuarkusConfigFactory() { - // todo: replace with {@code provider()} post-Java 11 - } - @Override public SmallRyeConfig getConfigFor(final SmallRyeConfigProviderResolver configProviderResolver, final ClassLoader classLoader) { @@ -30,15 +25,12 @@ public SmallRyeConfig getConfigFor(final SmallRyeConfigProviderResolver configPr } public static void setConfig(SmallRyeConfig config) { - SmallRyeConfigProviderResolver configProviderResolver = (SmallRyeConfigProviderResolver) SmallRyeConfigProviderResolver - .instance(); + ConfigProviderResolver configProviderResolver = ConfigProviderResolver.instance(); // Uninstall previous config if (QuarkusConfigFactory.config != null) { configProviderResolver.releaseConfig(QuarkusConfigFactory.config); QuarkusConfigFactory.config = null; } - // Also release the TCCL config, in case that config was not QuarkusConfigFactory.config - configProviderResolver.releaseConfig(Thread.currentThread().getContextClassLoader()); // Install new config if (config != null) { QuarkusConfigFactory.config = config; diff --git a/devtools/cli/src/main/java/io/quarkus/cli/common/OutputOptionMixin.java b/devtools/cli/src/main/java/io/quarkus/cli/common/OutputOptionMixin.java index 50bb36be6c48f..1da00c356683b 100644 --- a/devtools/cli/src/main/java/io/quarkus/cli/common/OutputOptionMixin.java +++ b/devtools/cli/src/main/java/io/quarkus/cli/common/OutputOptionMixin.java @@ -162,6 +162,11 @@ public int handleCommandException(Exception ex, String message) { CommandLine.UnmatchedArgumentException.printSuggestions((CommandLine.ParameterException) ex, out()); } error(message); + + if (!isShowErrors()) { + info("\nAdd the -e/--errors option to get more information about the error. Add the --verbose option to get even more details."); + } + return cmd.getExitCodeExceptionMapper() != null ? cmd.getExitCodeExceptionMapper().getExitCode(ex) : mixee.exitCodeOnInvalidInput(); } diff --git a/devtools/cli/src/main/java/io/quarkus/cli/common/TargetQuarkusPlatformGroup.java b/devtools/cli/src/main/java/io/quarkus/cli/common/TargetQuarkusPlatformGroup.java index 113d3c3d2a4ae..f1ba101c2cc35 100644 --- a/devtools/cli/src/main/java/io/quarkus/cli/common/TargetQuarkusPlatformGroup.java +++ b/devtools/cli/src/main/java/io/quarkus/cli/common/TargetQuarkusPlatformGroup.java @@ -9,7 +9,7 @@ public class TargetQuarkusPlatformGroup { static final String FULL_EXAMPLE = ToolsConstants.DEFAULT_PLATFORM_BOM_GROUP_ID + ":" - + ToolsConstants.DEFAULT_PLATFORM_BOM_ARTIFACT_ID + ":2.2.0.Final"; + + ToolsConstants.DEFAULT_PLATFORM_BOM_ARTIFACT_ID + ":3.15.2"; PlatformStreamCoords streamCoords = null; String validStream = null; @@ -20,7 +20,7 @@ public class TargetQuarkusPlatformGroup { CommandSpec spec; @CommandLine.Option(paramLabel = "platformKey:streamId", names = { "-S", - "--stream" }, description = "A target stream, for example:%n io.quarkus.platform:2.0") + "--stream" }, description = "A target stream, for example:%n 3.15 or io.quarkus.platform:3.15") void setStream(String stream) { stream = stream.trim(); if (!stream.isEmpty()) { @@ -39,7 +39,7 @@ void setStream(String stream) { "--platform-bom" }, description = "A specific Quarkus platform BOM, for example:%n" + " " + FULL_EXAMPLE + "%n" + " io.quarkus::999-SNAPSHOT" - + " 2.2.0.Final%n" + + " 3.15.2%n" + "Default groupId: " + ToolsConstants.DEFAULT_PLATFORM_BOM_GROUP_ID + "%n" + "Default artifactId: " + ToolsConstants.DEFAULT_PLATFORM_BOM_ARTIFACT_ID + "%n") void setPlatformBom(String bom) { diff --git a/devtools/cli/src/main/java/io/quarkus/cli/common/TargetQuarkusVersionGroup.java b/devtools/cli/src/main/java/io/quarkus/cli/common/TargetQuarkusVersionGroup.java index 53b2ff28a120b..1ed701fc35355 100644 --- a/devtools/cli/src/main/java/io/quarkus/cli/common/TargetQuarkusVersionGroup.java +++ b/devtools/cli/src/main/java/io/quarkus/cli/common/TargetQuarkusVersionGroup.java @@ -5,12 +5,12 @@ public class TargetQuarkusVersionGroup { @CommandLine.Option(paramLabel = "targetStream", names = { "-S", - "--stream" }, description = "A target stream, for example:%n 2.0") + "--stream" }, description = "A target stream, for example:%n 3.15") public String streamId; @CommandLine.Option(paramLabel = "targetPlatformVersion", names = { "-P", "--platform-version" }, description = "A specific target Quarkus platform version, for example:%n" - + " 2.2.0.Final%n") + + " 3.15.2%n") public String platformVersion; //@CommandLine.Option(names = { "-L", diff --git a/devtools/gradle/gradle-model/src/main/java/io/quarkus/gradle/dependency/ApplicationDeploymentClasspathBuilder.java b/devtools/gradle/gradle-model/src/main/java/io/quarkus/gradle/dependency/ApplicationDeploymentClasspathBuilder.java index 7fc07fdf4f152..e54be14cff418 100644 --- a/devtools/gradle/gradle-model/src/main/java/io/quarkus/gradle/dependency/ApplicationDeploymentClasspathBuilder.java +++ b/devtools/gradle/gradle-model/src/main/java/io/quarkus/gradle/dependency/ApplicationDeploymentClasspathBuilder.java @@ -6,6 +6,7 @@ import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -228,32 +229,39 @@ private void setUpDeploymentConfiguration() { configuration.setCanBeConsumed(false); Configuration enforcedPlatforms = this.getPlatformConfiguration(); configuration.extendsFrom(enforcedPlatforms); + Map> calculatedDependenciesByModeAndConfiguration = new HashMap<>(); ListProperty dependencyListProperty = project.getObjects().listProperty(Dependency.class); configuration.getDependencies().addAllLater(dependencyListProperty.value(project.provider(() -> { - ConditionalDependenciesEnabler cdEnabler = new ConditionalDependenciesEnabler(project, mode, - enforcedPlatforms); - final Collection> allExtensions = cdEnabler.getAllExtensions(); - Set> extensions = collectFirstMetQuarkusExtensions(getRawRuntimeConfiguration(), - allExtensions); - // Add conditional extensions - for (ExtensionDependency knownExtension : allExtensions) { - if (knownExtension.isConditional()) { - extensions.add(knownExtension); + String key = String.format("%s%s%s", mode, configuration.getName(), project.getName()); + if (!calculatedDependenciesByModeAndConfiguration.containsKey(key)) { + ConditionalDependenciesEnabler cdEnabler = new ConditionalDependenciesEnabler(project, mode, + enforcedPlatforms); + final Collection> allExtensions = cdEnabler.getAllExtensions(); + Set> extensions = collectFirstMetQuarkusExtensions(getRawRuntimeConfiguration(), + allExtensions); + // Add conditional extensions + for (ExtensionDependency knownExtension : allExtensions) { + if (knownExtension.isConditional()) { + extensions.add(knownExtension); + } } - } - final Set alreadyProcessed = new HashSet<>(extensions.size()); - final DependencyHandler dependencies = project.getDependencies(); - final Set deploymentDependencies = new HashSet<>(); - for (ExtensionDependency extension : extensions) { - if (!alreadyProcessed.add(extension.getExtensionId())) { - continue; - } + final Set alreadyProcessed = new HashSet<>(extensions.size()); + final DependencyHandler dependencies = project.getDependencies(); + final Set deploymentDependencies = new HashSet<>(); + for (ExtensionDependency extension : extensions) { + if (!alreadyProcessed.add(extension.getExtensionId())) { + continue; + } - deploymentDependencies.add( - DependencyUtils.createDeploymentDependency(dependencies, extension)); + deploymentDependencies.add( + DependencyUtils.createDeploymentDependency(dependencies, extension)); + } + calculatedDependenciesByModeAndConfiguration.put(key, deploymentDependencies); + return deploymentDependencies; + } else { + return calculatedDependenciesByModeAndConfiguration.get(key); } - return deploymentDependencies; }))); }); } diff --git a/devtools/gradle/gradle/libs.versions.toml b/devtools/gradle/gradle/libs.versions.toml index e4b86cbd74791..5508dac5d2176 100644 --- a/devtools/gradle/gradle/libs.versions.toml +++ b/devtools/gradle/gradle/libs.versions.toml @@ -6,7 +6,7 @@ kotlin = "2.0.21" smallrye-config = "3.10.2" junit5 = "5.10.5" -assertj = "3.26.3" +assertj = "3.27.0" [plugins] plugin-publish = { id = "com.gradle.plugin-publish", version.ref = "plugin-publish" } diff --git a/devtools/gradle/settings.gradle.kts b/devtools/gradle/settings.gradle.kts index 4d008c259a339..6532acd5a053f 100644 --- a/devtools/gradle/settings.gradle.kts +++ b/devtools/gradle/settings.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.gradle.develocity") version "3.18.2" + id("com.gradle.develocity") version "3.19" } develocity { diff --git a/devtools/maven/src/main/java/io/quarkus/maven/GenerateCodeMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/GenerateCodeMojo.java index c35a0befbbef0..d95e82c89028b 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/GenerateCodeMojo.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/GenerateCodeMojo.java @@ -1,5 +1,6 @@ package io.quarkus.maven; +import java.io.IOException; import java.lang.reflect.Method; import java.nio.file.Path; import java.util.List; @@ -16,6 +17,7 @@ import io.quarkus.bootstrap.app.CuratedApplication; import io.quarkus.bootstrap.classloading.QuarkusClassLoader; import io.quarkus.bootstrap.model.ApplicationModel; +import io.quarkus.bootstrap.util.BootstrapUtils; import io.quarkus.maven.dependency.ArtifactCoords; import io.quarkus.paths.PathCollection; import io.quarkus.paths.PathList; @@ -31,8 +33,11 @@ public class GenerateCodeMojo extends QuarkusBootstrapMojo { * Skip the execution of this mojo */ @Parameter(defaultValue = "false", property = "quarkus.generate-code.skip", alias = "quarkus.prepare.skip") - private boolean skipSourceGeneration = false; + boolean skipSourceGeneration = false; + /** + * Application launch mode for which to generate the source code. + */ @Parameter(defaultValue = "NORMAL", property = "launchMode") String mode; @@ -55,9 +60,8 @@ protected void doExecute() throws MojoExecutionException, MojoFailureException { path -> mavenProject().addCompileSourceRoot(path.toString()), false); } - void generateCode(PathCollection sourceParents, - Consumer sourceRegistrar, - boolean test) throws MojoFailureException, MojoExecutionException { + void generateCode(PathCollection sourceParents, Consumer sourceRegistrar, boolean test) + throws MojoExecutionException { final LaunchMode launchMode; if (test) { @@ -97,13 +101,31 @@ void generateCode(PathCollection sourceParents, if (deploymentClassLoader != null) { deploymentClassLoader.close(); } - // in case of test mode, we can't share the bootstrapped app with the testing plugins, so we are closing it right away + // In case of the test mode, we can't share the application model with the test plugins, so we are closing it right away, + // but we are serializing the application model so the test plugins can deserialize it from disk instead of re-initializing + // the resolver and re-resolving it as part of the test bootstrap if (test && curatedApplication != null) { - curatedApplication.close(); + var appModel = curatedApplication.getApplicationModel(); + closeApplication(LaunchMode.TEST); + if (isSerializeTestModel()) { + final int workspaceId = getWorkspaceId(); + if (workspaceId != 0) { + try { + BootstrapUtils.writeAppModelWithWorkspaceId(appModel, workspaceId, BootstrapUtils + .getSerializedTestAppModelPath(Path.of(mavenProject().getBuild().getDirectory()))); + } catch (IOException e) { + getLog().warn("Failed to serialize application model", e); + } + } + } } } } + protected boolean isSerializeTestModel() { + return false; + } + protected PathCollection getParentDirs(List sourceDirs) { if (sourceDirs.size() == 1) { return PathList.of(Path.of(sourceDirs.get(0)).getParent()); diff --git a/devtools/maven/src/main/java/io/quarkus/maven/GenerateCodeTestsMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/GenerateCodeTestsMojo.java index b644af7be0a18..ed5b9bd71e6a4 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/GenerateCodeTestsMojo.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/GenerateCodeTestsMojo.java @@ -13,15 +13,29 @@ import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; import io.quarkus.bootstrap.app.CuratedApplication; +import io.quarkus.bootstrap.model.ApplicationModel; import io.quarkus.builder.Json; import io.quarkus.maven.dependency.ResolvedDependency; import io.quarkus.runtime.LaunchMode; @Mojo(name = "generate-code-tests", defaultPhase = LifecyclePhase.GENERATE_TEST_SOURCES, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, threadSafe = true) public class GenerateCodeTestsMojo extends GenerateCodeMojo { + + /** + * A switch that enables or disables serialization of an {@link ApplicationModel} to a file for tests. + * Deserializing an application model when bootstrapping Quarkus tests has a performance advantage in that + * the tests will not have to initialize a Maven resolver and re-resolve the application model, which may save, + * depending on a project, ~80-95% of time on {@link ApplicationModel} resolution. + *

+ * Serialization of the test model is enabled by default. + */ + @Parameter(property = "quarkus.generate-code.serialize-test-model", defaultValue = "true") + boolean serializeTestModel; + @Override protected void doExecute() throws MojoExecutionException, MojoFailureException { generateCode(getParentDirs(mavenProject().getTestCompileSourceRoots()), @@ -32,6 +46,11 @@ protected void doExecute() throws MojoExecutionException, MojoFailureException { } } + @Override + protected boolean isSerializeTestModel() { + return serializeTestModel; + } + private boolean isTestWithNativeAgent() { String value = System.getProperty("quarkus.test.integration-test-profile"); if ("test-with-native-agent".equals(value)) { diff --git a/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java index b8cfa1396f9b3..fe53cd99d3433 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java @@ -296,6 +296,20 @@ protected CuratedApplication bootstrapApplication(LaunchMode mode) throws MojoEx return bootstrapProvider.bootstrapApplication(this, mode); } + protected void closeApplication(LaunchMode mode) { + bootstrapProvider.closeApplication(this, mode); + } + + /** + * Workspace ID associated with a given bootstrap mojo. + * If the returned value is {@code 0}, a workspace was not associated with the bootstrap mojo. + * + * @return workspace ID associated with a given bootstrap mojo + */ + protected int getWorkspaceId() { + return bootstrapProvider.getWorkspaceId(this); + } + protected CuratedApplication bootstrapApplication(LaunchMode mode, Consumer builderCustomizer) throws MojoExecutionException { return bootstrapProvider.bootstrapApplication(this, mode, builderCustomizer); diff --git a/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapProvider.java b/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapProvider.java index a2b09b2e7a06c..1341a7d14c9eb 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapProvider.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapProvider.java @@ -45,6 +45,7 @@ import io.quarkus.bootstrap.resolver.maven.EffectiveModelResolver; import io.quarkus.bootstrap.resolver.maven.IncubatingApplicationModelResolver; import io.quarkus.bootstrap.resolver.maven.MavenArtifactResolver; +import io.quarkus.bootstrap.resolver.maven.workspace.LocalProject; import io.quarkus.maven.components.ManifestSection; import io.quarkus.maven.components.QuarkusWorkspaceProvider; import io.quarkus.maven.dependency.ArtifactCoords; @@ -138,6 +139,21 @@ public CuratedApplication bootstrapApplication(QuarkusBootstrapMojo mojo, Launch return bootstrapper(mojo).bootstrapApplication(mojo, mode, builderCustomizer); } + public void closeApplication(QuarkusBootstrapMojo mojo, LaunchMode mode) { + bootstrapper(mojo).closeApplication(mode); + } + + /** + * Workspace ID associated with a given bootstrap mojo. + * If the returned value is {@code 0}, a workspace was not associated with the bootstrap mojo. + * + * @param mojo bootstrap mojo + * @return workspace ID associated with a given bootstrap mojo + */ + public int getWorkspaceId(QuarkusBootstrapMojo mojo) { + return bootstrapper(mojo).workspaceId; + } + public ApplicationModel getResolvedApplicationModel(ArtifactKey projectId, LaunchMode mode, String bootstrapId) { if (appBootstrapProviders.size() == 0) { return null; @@ -180,6 +196,7 @@ private static boolean isWorkspaceDiscovery(QuarkusBootstrapMojo mojo) { public class QuarkusMavenAppBootstrap implements Closeable { + private int workspaceId; private CuratedApplication prodApp; private CuratedApplication devApp; private CuratedApplication testApp; @@ -187,7 +204,7 @@ public class QuarkusMavenAppBootstrap implements Closeable { private MavenArtifactResolver artifactResolver(QuarkusBootstrapMojo mojo, LaunchMode mode) { try { if (mode == LaunchMode.DEVELOPMENT || mode == LaunchMode.TEST || isWorkspaceDiscovery(mojo)) { - return workspaceProvider.createArtifactResolver( + var resolver = workspaceProvider.createArtifactResolver( BootstrapMavenContext.config() // it's important to pass user settings in case the process was not launched using the original mvn script // for example using org.codehaus.plexus.classworlds.launcher.Launcher @@ -199,6 +216,11 @@ private MavenArtifactResolver artifactResolver(QuarkusBootstrapMojo mojo, Launch .setRemoteRepositories(mojo.remoteRepositories()) .setEffectiveModelBuilder(BootstrapMavenContextConfig .getEffectiveModelBuilderProperty(mojo.mavenProject().getProperties()))); + final LocalProject currentProject = resolver.getMavenContext().getCurrentProject(); + if (currentProject != null && workspaceId == 0) { + workspaceId = currentProject.getWorkspace().getId(); + } + return resolver; } // PROD packaging mode with workspace discovery disabled return MavenArtifactResolver.builder() @@ -376,6 +398,23 @@ protected CuratedApplication bootstrapApplication(QuarkusBootstrapMojo mojo, Lau return prodApp == null ? prodApp = doBootstrap(mojo, mode, builderCustomizer) : prodApp; } + protected void closeApplication(LaunchMode mode) { + if (mode == LaunchMode.DEVELOPMENT) { + if (devApp != null) { + devApp.close(); + devApp = null; + } + } else if (mode == LaunchMode.TEST) { + if (testApp != null) { + testApp.close(); + testApp = null; + } + } else if (prodApp != null) { + prodApp.close(); + prodApp = null; + } + } + protected ArtifactCoords managingProject(QuarkusBootstrapMojo mojo) { if (mojo.appArtifactCoords() == null) { return null; diff --git a/docs/pom.xml b/docs/pom.xml index 25280e567b7c7..8dd5d654a0a7c 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -29,7 +29,7 @@ 2.26.0.Final 37 11.1.0 - 7.0.0.202409031743-r + 7.1.0.202411261347-r https://quarkus.io https://github.com/quarkusio/quarkus diff --git a/docs/src/main/asciidoc/centralized-log-management.adoc b/docs/src/main/asciidoc/centralized-log-management.adoc index 4fbb13b06388c..f0127b3d339ce 100644 --- a/docs/src/main/asciidoc/centralized-log-management.adoc +++ b/docs/src/main/asciidoc/centralized-log-management.adoc @@ -250,7 +250,7 @@ For this you can use the same `docker-compose.yml` file as above but with a diff input { tcp { port => 4560 - coded => json + codec => json } } diff --git a/docs/src/main/asciidoc/command-mode-reference.adoc b/docs/src/main/asciidoc/command-mode-reference.adoc index 14f8f2f1831d0..074fe72adc894 100644 --- a/docs/src/main/asciidoc/command-mode-reference.adoc +++ b/docs/src/main/asciidoc/command-mode-reference.adoc @@ -255,7 +255,7 @@ Consequently, mocking CDI beans with `QuarkusMock` or `@InjectMock` is not suppo It is possible to mock CDI beans by leveraging xref:getting-started-testing.adoc#testing_different_profiles[test profiles] though. -For instance, in the following test, the singleton `CdiBean1` will be mocked by `MockedCdiBean1`: +For instance, in the following test, the launched application would receive a mocked singleton `CdiBean1`. The implementation `MockedCdiBean1` is provided by the test: [source,java] ---- diff --git a/docs/src/main/asciidoc/drools.adoc b/docs/src/main/asciidoc/drools.adoc new file mode 100644 index 0000000000000..02a3f46bfcc68 --- /dev/null +++ b/docs/src/main/asciidoc/drools.adoc @@ -0,0 +1,638 @@ +//// +This guide is maintained in the main Quarkus repository +and pull requests should be submitted there: +https://github.com/quarkusio/quarkus/tree/main/docs/src/main/asciidoc +//// += Defining and executing business rules with Drools +include::_attributes.adoc[] +:categories: rule engine +:summary: Drools is the most used rule engine implementation in the Java ecosystem. The purpose of this guide is to show how to use to define and execute business rules in Quarkus using Drools. +:topics: drools,rules,rule engine +:extensions: org.drools:drools-quarkus + +This guide demonstrates how your Quarkus application can use https://www.drools.org[Drools] to add intelligent automation +and power it up with the Drools rule engine. + +== Prerequisites + +include::{includes}/prerequisites.adoc[] + +== Introduction + +https://www.drools.org[Drools] is a set of projects focusing on intelligent automation and decision management, most notably providing a forward-chaining and backward-chaining inference-based rule engine, DMN decisions engine and other projects. A rule engine is a fundamental building block to create an expert system which, in artificial intelligence, is a computer system that emulates the decision-making ability of a human expert. You can read more information on the https://www.drools.org[Drools website]. + +Drools allows defining rules with 2 different programming styles: one more traditional based on the concepts of a KieBase acting as a repository of business rules and a KieSession storing and evaluating the runtime data against them, and the other using a Rule Unit as a single abstraction that encapsulates the definitions of both a set of rules and the facts against which those rules will be matched. + +Both these styles are fully supported in the Drools Quarkus extension and this document explains how to use both, outlining the pros and cons of each one. + +== Integrating the traditional Drools programming model with Quarkus + +This first example demonstrates how to define a set of rules using the traditional Drools style and how to expose their evaluation inside a REST endpoint through Quarkus. + +The domain model of this sample project is made only by two classes, a loan application + +[source,java] +---- +public class LoanApplication { + private String id; + private Applicant applicant; + private int amount; + private int deposit; + private boolean approved = false; + + public LoanApplication(String id, Applicant applicant, int amount, int deposit) { + this.id = id; + this.applicant = applicant; + this.amount = amount; + this.deposit = deposit; + } +} +---- + +and the applicant who requested it + +[source,java] +---- +public class Applicant { + private String name; + private int age; + + public Applicant(String name, int age) { + this.name = name; + this.age = age; + } +} +---- + +The rules set is made of business decisions to approve or reject an application plus one last rule collecting all the approved applications into a list. + +[source] +---- +global Integer maxAmount; +global java.util.List approvedApplications; + +rule LargeDepositApprove when + $l: LoanApplication( applicant.age >= 20, deposit >= 1000, amount <= maxAmount ) +then + modify($l) { setApproved(true) }; // loan is approved +end + +rule LargeDepositReject when + $l: LoanApplication( applicant.age >= 20, deposit >= 1000, amount > maxAmount ) +then + modify($l) { setApproved(false) }; // loan is rejected +end + +// ... more loans approval/rejections business rules ... + +rule CollectApprovedApplication when + $l: LoanApplication( approved ) +then + approvedApplications.add($l); // collect all approved loan applications +end +---- + +The goal that we want to achieve is putting the evaluation of these rules in a microservice, exposing them in a REST endpoint developed with Quarkus. To do so it is enough to add the Drools Quarkus extension among the dependencies of your project. + +[source,xml] +---- + + org.drools + drools-quarkus + +---- + +and at this point it is possible to obtain a reference to the KieSession evaluating the formerly defined rules and use it in a REST endpoint as it follows: + +[source,java] +---- +@Path("/find-approved") +public class FindApprovedLoansEndpoint { + + @Inject + KieRuntimeBuilder kieRuntimeBuilder; + + @POST() + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public List executeQuery(LoanAppDto loanAppDto) { + KieSession session = kieRuntimeBuilder.newKieSession(); + List approvedApplications = new ArrayList<>(); + + session.setGlobal("approvedApplications", approvedApplications); + session.setGlobal("maxAmount", loanAppDto.getMaxAmount()); + loanAppDto.getLoanApplications().forEach(session::insert); + + session.fireAllRules(); + session.dispose(); + return approvedApplications; + } +} +---- + +where an implementation of the `KieRuntimeBuilder` interface is automatically generated and made injectable for you by the Drools extension and allows to obtain with a single statement an instance of any KieBases and KieSessions defined in your Drools project. + +Here the `LoanAppDto` is a simple POJO used to submit multiple loan application to the same KieSession + +[source,java] +---- +public class LoanAppDto { + private int maxAmount; + private List loanApplications; + + public int getMaxAmount() { + return maxAmount; + } + + public void setMaxAmount(int maxAmount) { + this.maxAmount = maxAmount; + } + + public List getLoanApplications() { + return loanApplications; + } + + public void setLoanApplications(List loanApplications) { + this.loanApplications = loanApplications; + } +} +---- + +thus trying for example to invoke that endpoint with a set of loan applications + +[source] +---- +curl -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' -d +'{"maxAmount":5000,"loanApplications":[ + {"id":"ABC10001","amount":2000,"deposit":1000,"applicant":{"age":45,"name":"John"}}, + {"id":"ABC10002","amount":5000,"deposit":100,"applicant":{"age":25,"name":"Paul"}}, + {"id":"ABC10015","amount":1000,"deposit":100,"applicant":{"age":12,"name":"George"}} +]}' +http://localhost:8080/find-approved +---- + +the rule engine will evaluate them against the business rules we have configured before, returning the only one that in this case can be approved according to them + +[source] +---- +[{"id":"ABC10001","applicant":{"name":"John","age":45},"amount":2000,"deposit":1000,"approved":true}] +---- + +== Moving to the rule unit programming model + +A rule unit is a new concept introduced in Drools encapsulating both a set of rules and the facts against which those rules will be matched. It comes with a second abstraction called data source, defining the sources through which the facts are inserted, acting in practice as typed entry-points. There are two types of data sources: + +* DataStream: an append-only data source +** subscribers only receive new (and possibly past) messages +** cannot update/remove +** stream may also be hot/cold in “reactive streams” terminology +* DataStore: data source for modifiable data +** subscribers may act upon the data store, by acting upon the fact handle + +In order to use rule units in our quarkus application it is necessary to add a second dependency. + +[source,xml] +---- + + org.drools + drools-ruleunits-engine + +---- + +In essence a rule unit is made of 2 strictly related parts: the definition of the fact to be evaluated and the set of rules evaluating them. The first part is implemented with a POJO, that for the loan example could be something like the following: + +[source,java] +---- +package org.loans; + +import org.drools.ruleunits.api.DataSource; +import org.drools.ruleunits.api.DataStore; +import org.drools.ruleunits.api.RuleUnitData; + +public class LoanUnit implements RuleUnitData { + + private int maxAmount; + private DataStore loanApplications; + + public LoanUnit() { + this(DataSource.createStore(), 0); + } + + public LoanUnit(DataStore loanApplications, int maxAmount) { + this.loanApplications = loanApplications; + this.maxAmount = maxAmount; + } + + public DataStore getLoanApplications() { + return loanApplications; + } + + public void setLoanApplications(DataStore loanApplications) { + this.loanApplications = loanApplications; + } + + public int getMaxAmount() { + return maxAmount; + } + + public void setMaxAmount(int maxAmount) { + this.maxAmount = maxAmount; + } +} +---- + +Here instead of using the `LoanAppDto` that we introduced to marshall/unmarshall the JSON requests we are binding directly the class representing the rule unit. The two relevant differences are that it implements the `RuleUnitData` interface and uses a `DataStore` instead of a plain `List` containing the loan applications to be approved. The first is just a marker interface to notify the engine that this class is part of a rule unit definition. The use of a `DataStore` is necessary to let the rule engine to react accordingly to the changes by firing new rules and triggering other rules. In the example, the consequences of the rules modify the approved property of the loan applications. Conversely, the `maxAmount` value can be considered a configuration parameter of the rule unit and left as it is: it will automatically be processed during the rules evaluation with the same semantic of a global, and automatically set from the value passed by the JSON request as in the first example, so you will still be allowed to use it in your rules. + +The second part of the rule unit is the drl file containing the rules belonging to this unit. + +[source] +---- +package org.loans; + +unit LoanUnit; // no need to using globals, all variables and facts are stored in the rule unit + +rule LargeDepositApprove when + $l: /loanApplications[ applicant.age >= 20, deposit >= 1000, amount <= maxAmount ] // oopath style +then + modify($l) { setApproved(true) }; +end + +rule LargeDepositReject when + $l: /loanApplications[ applicant.age >= 20, deposit >= 1000, amount > maxAmount ] +then + modify($l) { setApproved(false) }; +end + +// ... more loans approval/rejections business rules ... + +// approved loan applications are now retrieved through a query +query FindApproved + $l: /loanApplications[ approved ] +end +---- + +This rules file must declare the same package and a unit with the same name of the Java class implementing the `RuleUnitData` interface in order to state that they belong to the same rule unit. + +This file has also been rewritten using the new OOPath notation: as anticipated, here the data source acts as a typed entry-point and the oopath expression has its name as root while the constraints are in square brackets, like in the following example. + +[source] +---- +$l: /loanApplications[ applicant.age >= 20, deposit >= 1000, amount <= maxAmount ] +---- + +Alternatively you can still use the old DRL syntax, specifying the name of the data source as an entry-point, with the drawback that in this case you need to specify again the type of the matched object, even if the engine can infer it from the type of the datasource, as it follows. + +[source] +---- +$l: LoanApplication( applicant.age >= 20, deposit >= 1000, amount <= maxAmount ) from entry-point loanApplications +---- + +Finally note that the last rule collecting all the approved loan applications into a global `List` has been replaced by a query simply retrieving them. One of the advantages in using a rule unit is that it clearly defines the context of computation, in other terms the facts to be passed in input to the rule evaluation. Similarly, the query defines what is the output expected by this evaluation. + +This clear definition of the computation boundaries allows Drools to also automatically generate a class executing the query and returning its results, together with a REST endpoint taking the rule unit as input, passing it to the former query executor and returning its as output. + +You can have as many query as you want and for each of them it will be generated a different REST endpoint with the same name of the query transformed from camel case (like `FindApproved`) to dash separated (like `find-approved`). + +== A more comprehensive example + +In this more comprehensive and complete example, we will augment a basic Quarkus application with a few simple rules to infer potential issues with the status of a home automation setup. + +We will define a Drools Rule Unit and the rules in the DRL format. + +We will wire the Rule Unit into a standard Quarkus CDI bean, for use in the Quarkus application (for instance, wiring MQTT messages from Kafka, etc.). + +=== Prerequisites + +To complete this guide, you need: + +* less than 15 minutes +* an IDE +* JDK 17+ installed with `JAVA_HOME` configured appropriately +* Apache Maven 3.9.3+ +* Docker +* link:{https://quarkus.io/guides/building-native-image}[GraalVM installed] if you want to run in native mode + +=== Creating the Maven Project + +First, we need a new Quarkus project. +To create a new Quarkus project, you can reference the link:{https://quarkus.io/guides/maven-tooling}[Quarkus and Maven Guide] + +When you have your Quarkus project configured, you can add the Drools Quarkus extensions to your project by adding the following dependencies to your `pom.xml`: + +[source,xml,subs=attributes+] +---- + + org.drools + drools-quarkus + + + org.drools + drools-ruleunits-engine + + + + org.assertj + assertj-core + test + +---- + +=== Writing the application + +Let's start from the application domain model. + +This application goal is to infer potential issues with the status of a home automation setup, so we create the necessary domain models to represent status of sensors, devices and other things inside the house. + +Light device domain model: + +[source,java] +---- +package org.drools.quarkus.quickstart.test.model; + +public class Light { + private final String name; + private Boolean powered; + + public Light(String name, Boolean powered) { + this.name = name; + this.powered = powered; + } + + // getters, setters, etc. +} +---- + +CCTV security camera domain model: + +[source,java] +---- +package org.drools.quarkus.quickstart.test.model; + +public class CCTV { + private final String name; + private Boolean powered; + + public CCTV(String name, Boolean powered) { + this.name = name; + this.powered = powered; + } + + // getters, setters, etc. +} +---- + +Smartphone detected in WiFi domain model: + +[source,java] +---- +package org.drools.quarkus.quickstart.test.model; + +public class Smartphone { + private final String name; + + public Smartphone(String name) { + this.name = name; + } + + // getters, setters, etc. +} +---- + +Alert class to hold information of the potential detected problems: + +[source,java] +---- +package org.drools.quarkus.quickstart.test.model; + +public class Alert { + private final String notification; + + public Alert(String notification) { + this.notification = notification; + } + + // getters, setters, etc. +} +---- + +Next, we create a rule file `rules.drl` inside the `src/main/resources/org/drools/quarkus/quickstart/test` folder of the Quarkus project. + +[source] +---- +package org.drools.quarkus.quickstart.test; + +unit HomeRuleUnitData; + +import org.drools.quarkus.quickstart.test.model.*; + +rule "No lights on while outside" +when + $l: /lights[ powered == true ]; + not( /smartphones ); +then + alerts.add(new Alert("You might have forgot one light powered on: " + $l.getName())); +end + +query "AllAlerts" + $a: /alerts; +end + +rule "No camera when present at home" +when + accumulate( $s: /smartphones ; $count : count($s) ; $count >= 1 ); + $l: /cctvs[ powered == true ]; +then + alerts.add(new Alert("One CCTV is still operating: " + $l.getName())); +end +---- + +In this file there are some example rules to decide whether the overall status of the house is deemed inappropriate, triggering the necessary `Alert` (s). + +Rule Unit a central paradigm introduced in Drools 8 that helps users to encapsulate the set of rules and the facts against which those rules will be matched; you can read more information in the https://www.drools.org/learn/documentation.html[Drools documentation]. + +The facts will be inserted into a `DataStore`, a type-safe entry point. To make everything work, we need to define both the RuleUnit and the DataStore. + +[source,java] +---- +package org.drools.quarkus.quickstart.test; + +import org.drools.quarkus.quickstart.test.model.Alert; +import org.drools.quarkus.quickstart.test.model.CCTV; +import org.drools.quarkus.quickstart.test.model.Light; +import org.drools.quarkus.quickstart.test.model.Smartphone; +import org.drools.ruleunits.api.DataSource; +import org.drools.ruleunits.api.DataStore; +import org.drools.ruleunits.api.RuleUnitData; + +public class HomeRuleUnitData implements RuleUnitData { + + private final DataStore lights; + private final DataStore cctvs; + private final DataStore smartphones; + + private final DataStore alerts = DataSource.createStore(); + + public HomeRuleUnitData() { + this(DataSource.createStore(), DataSource.createStore(), DataSource.createStore()); + } + + public HomeRuleUnitData(DataStore lights, DataStore cctvs, DataStore smartphones) { + this.lights = lights; + this.cctvs = cctvs; + this.smartphones = smartphones; + } + + public DataStore getLights() { + return lights; + } + + public DataStore getCctvs() { + return cctvs; + } + + public DataStore getSmartphones() { + return smartphones; + } + + public DataStore getAlerts() { + return alerts; + } +} +---- + +=== Testing the Application + +We can create a standard Quarkus and JUnit test to check the behaviour of the Rule Unit and the defined rules, accordingly to a certain set of scenarios. + +[source,java] +---- +package org.drools.quarkus.quickstart.test; + +@QuarkusTest +public class RuntimeIT { + + @Inject + RuleUnit ruleUnit; + + @Test + public void testRuleOutside() { + HomeRuleUnitData homeUnitData = new HomeRuleUnitData(); + homeUnitData.getLights().add(new Light("living room", true)); + homeUnitData.getLights().add(new Light("bedroom", false)); + homeUnitData.getLights().add(new Light("bathroom", false)); + + RuleUnitInstance unitInstance = ruleUnit.createInstance(homeUnitData); + List> queryResults = unitInstance.executeQuery("AllAlerts"); + assertThat(queryResults).isNotEmpty().anyMatch(kv -> kv.containsValue(new Alert("You might have forgot one light powered on: living room"))); + } + + @Test + public void testRuleInside() { + HomeRuleUnitData homeUnitData = new HomeRuleUnitData(); + homeUnitData.getLights().add(new Light("living room", true)); + homeUnitData.getLights().add(new Light("bedroom", false)); + homeUnitData.getLights().add(new Light("bathroom", false)); + homeUnitData.getCctvs().add(new CCTV("security camera 1", false)); + homeUnitData.getCctvs().add(new CCTV("security camera 2", true)); + homeUnitData.getSmartphones().add(new Smartphone("John Doe's phone")); + + RuleUnitInstance unitInstance = ruleUnit.createInstance(homeUnitData); + List> queryResults = unitInstance.executeQuery("AllAlerts"); + assertThat(queryResults).isNotEmpty().anyMatch(kv -> kv.containsValue(new Alert("One CCTV is still operating: security camera 2"))); + } + + @Test + public void testNoAlerts() { + HomeRuleUnitData homeUnitData = new HomeRuleUnitData(); + homeUnitData.getLights().add(new Light("living room", false)); + homeUnitData.getLights().add(new Light("bedroom", false)); + homeUnitData.getLights().add(new Light("bathroom", false)); + homeUnitData.getCctvs().add(new CCTV("security camera 1", true)); + homeUnitData.getCctvs().add(new CCTV("security camera 2", true)); + + RuleUnitInstance unitInstance = ruleUnit.createInstance(homeUnitData); + List> queryResults = unitInstance.executeQuery("AllAlerts"); + assertThat(queryResults).isEmpty(); + } +} +---- + +=== Wiring the Rule Unit with Quarkus CDI beans + +We can now wire the Rule Unit into a standard Quarkus CDI bean, for general use in the Quarkus application. + +For example, this might later be helpful to wire device status reporting through MQTT via Kafka, using the appropriate Quarkus extensions. + +We create a simple CDI bean to abstract away the Rule Unit API usage with: + +[source,java] +---- +package org.drools.quarkus.quickstart.test; + +@ApplicationScoped +public class HomeAlertsBean { + + @Inject + RuleUnit ruleUnit; + + public Collection computeAlerts(Collection lights, Collection cameras, Collection phones) { + HomeRuleUnitData homeUnitData = new HomeRuleUnitData(); + lights.forEach(homeUnitData.getLights()::add); + cameras.forEach(homeUnitData.getCctvs()::add); + phones.forEach(homeUnitData.getSmartphones()::add); + + RuleUnitInstance unitInstance = ruleUnit.createInstance(homeUnitData); + var queryResults = unitInstance.executeQuery("AllAlerts"); + List results = queryResults.stream() + .flatMap(m -> m.values().stream() + .filter(Alert.class::isInstance) + .map(Alert.class::cast)) + .collect(Collectors.toList()); + return results; + } +} +---- + +The same test scenarios can be refactored using this CDI bean accordingly. + +[source,java] +---- +package org.drools.quarkus.quickstart.test; + +@QuarkusTest +public class BeanTest { + + @Inject + HomeAlertsBean alerts; + + @Test + public void testRuleOutside() { + Collection computeAlerts = alerts.computeAlerts( + List.of(new Light("living room", true), new Light("bedroom", false), new Light("bathroom", false)), + Collections.emptyList(), + Collections.emptyList()); + + assertThat(computeAlerts).isNotEmpty().contains(new Alert("You might have forgot one light powered on: living room")); + } + + @Test + public void testRuleInside() { + Collection computeAlerts = alerts.computeAlerts( + List.of(new Light("living room", true), new Light("bedroom", false), new Light("bathroom", false)), + List.of(new CCTV("security camera 1", false), new CCTV("security camera 2", true)), + List.of(new Smartphone("John Doe's phone"))); + + assertThat(computeAlerts).isNotEmpty().contains(new Alert("One CCTV is still operating: security camera 2")); + } + + @Test + public void testNoAlerts() { + Collection computeAlerts = alerts.computeAlerts( + List.of(new Light("living room", false), new Light("bedroom", false), new Light("bathroom", false)), + List.of(new CCTV("security camera 1", true), new CCTV("security camera 2", true)), + Collections.emptyList()); + + assertThat(computeAlerts).isEmpty(); + } +} +---- diff --git a/docs/src/main/asciidoc/getting-started-testing.adoc b/docs/src/main/asciidoc/getting-started-testing.adoc index 916b16f57fd08..35c28cc97071a 100644 --- a/docs/src/main/asciidoc/getting-started-testing.adoc +++ b/docs/src/main/asciidoc/getting-started-testing.adoc @@ -369,6 +369,8 @@ public class GreetingServiceTest { ---- <1> The `GreetingService` bean will be injected into the test +TIP: If you want to inject/test a `@SessionScoped` bean then it's very likely that the session context is not active and you would receive the `ContextNotActiveException` when a method of the injected bean is invoked. However, it's possible to use the `@io.quarkus.test.ActivateSessionContext` interceptor binding to activate the session context for a specific business method. Please read the javadoc for futher limitations. + == Applying Interceptors to Tests As mentioned above Quarkus tests are actually full CDI beans, and as such you can apply CDI interceptors as you would @@ -472,24 +474,10 @@ a bit slower, as it adds a shutdown/startup cycle to the test time, but gives a To reduce the amount of times Quarkus needs to restart, `io.quarkus.test.junit.util.QuarkusTestProfileAwareClassOrderer` is registered as a global `ClassOrderer` as described in the link:https://junit.org/junit5/docs/current/user-guide/#writing-tests-test-execution-order-classes[JUnit 5 User Guide]. -The behavior of this `ClassOrderer` is configurable via `junit-platform.properties` (see the source code or javadoc for more details). -It can also be disabled entirely by setting another `ClassOrderer` that is provided by JUnit 5 or even your own custom one. + -Please note that as of JUnit 5.8.2 link:https://github.com/junit-team/junit5/issues/2794[only a single `junit-platform.properties` is picked up and a warning is logged if more than one is found]. -If you encounter such warnings, you can get rid of them by removing the Quarkus-supplied `junit-platform.properties` from the classpath via an exclusion: -[source,xml] ----- - - io.quarkus - quarkus-junit5 - test - - - io.quarkus - quarkus-junit5-properties - - - ----- +The behavior of this `ClassOrderer` is configurable via `application.properties` using the property +`quarkus.test.class-orderer`. The property accepts the FQCN of the `ClassOrderer` to use. If the class cannot be found, +it fallbacks to JUnit default behaviour which does not set a `ClassOrderer` at all. It can also be disabled entirely by +setting another `ClassOrderer` that is provided by JUnit 5 or even your own custom one. === Writing a Profile diff --git a/docs/src/main/asciidoc/images/oidc-apple-1.png b/docs/src/main/asciidoc/images/oidc-apple-1.png index 984e71462b51b..3424675d54cc4 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-1.png and b/docs/src/main/asciidoc/images/oidc-apple-1.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-10.png b/docs/src/main/asciidoc/images/oidc-apple-10.png index 96c897799905f..72c002b5cc2e2 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-10.png and b/docs/src/main/asciidoc/images/oidc-apple-10.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-11.png b/docs/src/main/asciidoc/images/oidc-apple-11.png index 2182c11e558d6..5b7d0bac59792 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-11.png and b/docs/src/main/asciidoc/images/oidc-apple-11.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-12.png b/docs/src/main/asciidoc/images/oidc-apple-12.png index d7cfb8717ff71..fb0d88e1597f0 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-12.png and b/docs/src/main/asciidoc/images/oidc-apple-12.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-13.png b/docs/src/main/asciidoc/images/oidc-apple-13.png index 084ba635f90f7..90f48cd57740e 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-13.png and b/docs/src/main/asciidoc/images/oidc-apple-13.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-14.png b/docs/src/main/asciidoc/images/oidc-apple-14.png index 526ae9cf232c8..55a31882c92c3 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-14.png and b/docs/src/main/asciidoc/images/oidc-apple-14.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-15.png b/docs/src/main/asciidoc/images/oidc-apple-15.png index 3188d816ee4c0..65a1b662e0859 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-15.png and b/docs/src/main/asciidoc/images/oidc-apple-15.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-16.png b/docs/src/main/asciidoc/images/oidc-apple-16.png index 470c4d7f0d6cd..6e40d06d8521e 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-16.png and b/docs/src/main/asciidoc/images/oidc-apple-16.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-17.png b/docs/src/main/asciidoc/images/oidc-apple-17.png index e08e7a11dd09a..8c825686da181 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-17.png and b/docs/src/main/asciidoc/images/oidc-apple-17.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-18.png b/docs/src/main/asciidoc/images/oidc-apple-18.png index e56e0633d495a..9f9e03b696412 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-18.png and b/docs/src/main/asciidoc/images/oidc-apple-18.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-19.png b/docs/src/main/asciidoc/images/oidc-apple-19.png index 35f68417682d9..b60337825088a 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-19.png and b/docs/src/main/asciidoc/images/oidc-apple-19.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-2.png b/docs/src/main/asciidoc/images/oidc-apple-2.png index 045ec05eeb1f1..1ad292aad7315 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-2.png and b/docs/src/main/asciidoc/images/oidc-apple-2.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-20.png b/docs/src/main/asciidoc/images/oidc-apple-20.png index 995b058ea3c07..de3c92bf1279c 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-20.png and b/docs/src/main/asciidoc/images/oidc-apple-20.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-21.png b/docs/src/main/asciidoc/images/oidc-apple-21.png index 75236c8d8a953..31b53522ecb66 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-21.png and b/docs/src/main/asciidoc/images/oidc-apple-21.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-22.png b/docs/src/main/asciidoc/images/oidc-apple-22.png index 33380e8d4ff4e..3684318f015b0 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-22.png and b/docs/src/main/asciidoc/images/oidc-apple-22.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-3.png b/docs/src/main/asciidoc/images/oidc-apple-3.png index 868440a5a6760..1649628657976 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-3.png and b/docs/src/main/asciidoc/images/oidc-apple-3.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-4.png b/docs/src/main/asciidoc/images/oidc-apple-4.png index 95c8e1125a7a6..adeeaec171b5a 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-4.png and b/docs/src/main/asciidoc/images/oidc-apple-4.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-5.png b/docs/src/main/asciidoc/images/oidc-apple-5.png index 5fd241e2d4bdb..87e081d264726 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-5.png and b/docs/src/main/asciidoc/images/oidc-apple-5.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-6.png b/docs/src/main/asciidoc/images/oidc-apple-6.png index 86ffd3348c463..e2f6762c6076d 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-6.png and b/docs/src/main/asciidoc/images/oidc-apple-6.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-7.png b/docs/src/main/asciidoc/images/oidc-apple-7.png index 3129b8bada98b..92cb700ce73be 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-7.png and b/docs/src/main/asciidoc/images/oidc-apple-7.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-8.png b/docs/src/main/asciidoc/images/oidc-apple-8.png index 053ea35b3abcc..1e1ebd105ff0e 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-8.png and b/docs/src/main/asciidoc/images/oidc-apple-8.png differ diff --git a/docs/src/main/asciidoc/images/oidc-apple-9.png b/docs/src/main/asciidoc/images/oidc-apple-9.png index 972daafd66443..db2a537fa8c1a 100644 Binary files a/docs/src/main/asciidoc/images/oidc-apple-9.png and b/docs/src/main/asciidoc/images/oidc-apple-9.png differ diff --git a/docs/src/main/asciidoc/images/webauthn-1.png b/docs/src/main/asciidoc/images/webauthn-1.png index 70b1764e343ed..515e71df96b04 100644 Binary files a/docs/src/main/asciidoc/images/webauthn-1.png and b/docs/src/main/asciidoc/images/webauthn-1.png differ diff --git a/docs/src/main/asciidoc/images/webauthn-2.png b/docs/src/main/asciidoc/images/webauthn-2.png index 760faf4a61506..e9522798b0152 100644 Binary files a/docs/src/main/asciidoc/images/webauthn-2.png and b/docs/src/main/asciidoc/images/webauthn-2.png differ diff --git a/docs/src/main/asciidoc/images/webauthn-4.png b/docs/src/main/asciidoc/images/webauthn-4.png index 2da3b1d5a176e..934a175c7bdcd 100644 Binary files a/docs/src/main/asciidoc/images/webauthn-4.png and b/docs/src/main/asciidoc/images/webauthn-4.png differ diff --git a/docs/src/main/asciidoc/images/webauthn-5.png b/docs/src/main/asciidoc/images/webauthn-5.png index 042d943a3fb71..19860f16d5be2 100644 Binary files a/docs/src/main/asciidoc/images/webauthn-5.png and b/docs/src/main/asciidoc/images/webauthn-5.png differ diff --git a/docs/src/main/asciidoc/images/webauthn-custom-login.svg b/docs/src/main/asciidoc/images/webauthn-custom-login.svg new file mode 100644 index 0000000000000..ed2d6dee9c081 --- /dev/null +++ b/docs/src/main/asciidoc/images/webauthn-custom-login.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/src/main/asciidoc/images/webauthn-custom-register.svg b/docs/src/main/asciidoc/images/webauthn-custom-register.svg new file mode 100644 index 0000000000000..04628daff7c18 --- /dev/null +++ b/docs/src/main/asciidoc/images/webauthn-custom-register.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/src/main/asciidoc/images/webauthn-login.svg b/docs/src/main/asciidoc/images/webauthn-login.svg new file mode 100644 index 0000000000000..eaff8b619a2d7 --- /dev/null +++ b/docs/src/main/asciidoc/images/webauthn-login.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/src/main/asciidoc/images/webauthn-register.svg b/docs/src/main/asciidoc/images/webauthn-register.svg new file mode 100644 index 0000000000000..7b6405aa86da0 --- /dev/null +++ b/docs/src/main/asciidoc/images/webauthn-register.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/src/main/asciidoc/kafka-dev-services.adoc b/docs/src/main/asciidoc/kafka-dev-services.adoc index cbe41d890c621..590f2ffda8770 100644 --- a/docs/src/main/asciidoc/kafka-dev-services.adoc +++ b/docs/src/main/asciidoc/kafka-dev-services.adoc @@ -56,8 +56,8 @@ Dev Services for Kafka supports https://redpanda.com[Redpanda], https://github/o and https://strimzi.io[Strimzi] (in https://github.com/apache/kafka/blob/trunk/config/kraft/README.md[Kraft] mode) images. **Redpanda** is a Kafka compatible event streaming platform. -Because it provides a fast startup times, Dev Services defaults to Redpanda images from `vectorized/redpanda`. -You can select any version from https://hub.docker.com/r/vectorized/redpanda. +Because it provides a fast startup times, Dev Services defaults to Redpanda images from `redpandadata/redpanda`. +You can select any version from https://hub.docker.com/r/redpandadata/redpanda. **kafka-native** provides images of standard Apache Kafka distribution compiled to native binary using Quarkus and GraalVM. While still being _experimental_, it provides very fast startup times with small footprint. diff --git a/docs/src/main/asciidoc/native-reference.adoc b/docs/src/main/asciidoc/native-reference.adoc index f4e6e491bf800..c5f8f2f83dcbb 100644 --- a/docs/src/main/asciidoc/native-reference.adoc +++ b/docs/src/main/asciidoc/native-reference.adoc @@ -598,11 +598,11 @@ invoke Maven's `verify` goal with `-DskipITs=false -Dquarkus.test.integration-te generate the native image configuration. For example: -[source,bash] +[source,bash,subs=attributes+] ---- $ ./mvnw verify -DskipITs=false -Dquarkus.test.integration-test-profile=test-with-native-agent ... -[INFO] --- failsafe:3.5.0:integration-test (default) @ new-project --- +[INFO] --- failsafe:3.5.2:integration-test (default) @ new-project --- ... [INFO] ------------------------------------------------------- [INFO] T E S T S @@ -660,7 +660,7 @@ This can be useful to verify that the native integration tests work as expected, assuming that the JVM unit tests have generated the correct native image configuration. The typical workflow here would be to first run the integration tests with the native image agent as shown in the previous section: -[source,bash] +[source,bash,subs=attributes+] ---- $ ./mvnw verify -DskipITs=false -Dquarkus.test.integration-test-profile=test-with-native-agent ... @@ -671,7 +671,7 @@ $ ./mvnw verify -DskipITs=false -Dquarkus.test.integration-test-profile=test-wit And then request a native build passing in the configuration apply flag. A message during the native build process will indicate that the native image agent generated configuration files are being applied: -[source,bash] +[source,bash,subs=attributes+] ---- $ ./mvnw verify -Dnative -Dquarkus.native.agent-configuration-apply ... @@ -702,7 +702,7 @@ and confirm that the class and/or package making the call or being accessed is n If the missing entry is related to some resource, you should inspect the Quarkus build debug output and verify which resource patterns are being discarded, e.g. -[source,bash] +[source,bash,subs=attributes+] ---- $ ./mvnw -X verify -DskipITs=false -Dquarkus.test.integration-test-profile=test-with-native-agent ... diff --git a/docs/src/main/asciidoc/observability-devservices-lgtm.adoc b/docs/src/main/asciidoc/observability-devservices-lgtm.adoc index 90b65608a3dc5..5d207a4791ecc 100644 --- a/docs/src/main/asciidoc/observability-devservices-lgtm.adoc +++ b/docs/src/main/asciidoc/observability-devservices-lgtm.adoc @@ -30,7 +30,7 @@ Add the Quarkus Grafana OTel LGTM sink (where data goes) extension to your build [source,gradle,role="secondary asciidoc-tabs-target-sync-gradle"] .build.gradle ---- -implementation("quarkus-observability-devservices-lgtm") +implementation("io.quarkus:quarkus-observability-devservices-lgtm") ---- === Metrics diff --git a/docs/src/main/asciidoc/quartz.adoc b/docs/src/main/asciidoc/quartz.adoc index bfdf94fc25eef..329160c926b81 100644 --- a/docs/src/main/asciidoc/quartz.adoc +++ b/docs/src/main/asciidoc/quartz.adoc @@ -7,15 +7,12 @@ https://github.com/quarkusio/quarkus/tree/main/docs/src/main/asciidoc include::_attributes.adoc[] :categories: miscellaneous :summary: You need clustering support for your scheduled tasks? This guide explains how to use the Quartz extension for that. -:extension-status: preview :topics: scheduling,cronjob,quartz :extensions: io.quarkus:quarkus-quartz Modern applications often need to run specific tasks periodically. In this guide, you learn how to schedule periodic clustered tasks using the http://www.quartz-scheduler.org/[Quartz] extension. -include::{includes}/extension-status.adoc[] - TIP: If you only need to run in-memory scheduler use the xref:scheduler.adoc[Scheduler] extension. == Prerequisites diff --git a/docs/src/main/asciidoc/rest-client.adoc b/docs/src/main/asciidoc/rest-client.adoc index 71900e4abfd9d..cec13b5a7bd6c 100644 --- a/docs/src/main/asciidoc/rest-client.adoc +++ b/docs/src/main/asciidoc/rest-client.adoc @@ -325,6 +325,38 @@ public interface ExtensionsService { } ---- +=== Dynamic base URLs + +The REST client allows for a per invocation override of the base URL using the `io.quarkus.rest.client.reactive.Url` annotation. + +Here is a simple example: + +[source, java] +---- +package org.acme.rest.client; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; +import java.util.Set; + +import io.quarkus.rest.client.reactive.Url; + +@Path("/extensions") +@RegisterRestClient +public interface ExtensionsService { + + @GET + @Path("/stream/{stream}") + Set getByStream(@Url String url, @PathParam("stream") String stream, @QueryParam("id") String id); +} +---- + +When the `url` parameter is non-null, it will override the base URL that is configured for the client (the default base URL configuration is still mandatory). + === Sending large payloads The REST Client is capable of sending arbitrarily large HTTP bodies without buffering the contents in memory, if one of the following types is used: @@ -460,18 +492,33 @@ quarkus.rest-client.extensions-api.scope=jakarta.inject.Singleton Setting the base URL of the client is **mandatory**, however the REST Client supports per-invocation overrides of the base URL using the `@io.quarkus.rest.client.reactive.Url` annotation. ==== -=== Disabling Hostname Verification +=== Trusting all certificates and Disabling SSL hostname verification -To disable the SSL hostname verification for a specific REST client, add the following property to your configuration: +[WARNING] +==== +This properties set should not be used in production. +==== +You can configure TLS connection of specific REST client to trust all certificates and disable the hostname verification using tls extension. +First of all, you should configure tls configuration bucket. + +To trust all certificates: [source,properties] ---- -quarkus.rest-client.extensions-api.verify-host=false +quarkus.tls.tls-disabled.trust-all=true +---- + +To disable SSL hostname verification: +[source,properties] +---- +quarkus.tls.tls-disabled.hostname-verification-algorithm=NONE +---- + +Finally, lets configure our REST client with apropriate tls configuration name: +[source,properties] +---- +quarkus.rest-client.extensions-api.tls-configuration-name=tls-disabled ---- -[WARNING] -==== -This setting should not be used in production as it will disable the SSL hostname verification. -==== === HTTP/2 Support @@ -2054,7 +2101,7 @@ and limitations: - the default scope of the client for the new extension is `@ApplicationScoped` while the `quarkus-resteasy-client` defaults to `@Dependent` To change this behavior, set the `quarkus.rest-client.scope` property to the fully qualified scope name. -- it is not possible to set `HostnameVerifier` or `SSLContext` +- it is not possible to set `SSLContext` - a few things that don't make sense for a non-blocking implementations, such as setting the `ExecutorService`, don't work == Further reading diff --git a/docs/src/main/asciidoc/rest-virtual-threads.adoc b/docs/src/main/asciidoc/rest-virtual-threads.adoc index a220aec16ce0f..9de9a9bf4c728 100644 --- a/docs/src/main/asciidoc/rest-virtual-threads.adoc +++ b/docs/src/main/asciidoc/rest-virtual-threads.adoc @@ -53,7 +53,7 @@ and in particular, adds the following dependencies: .build.gradle ---- implementation("io.quarkus:quarkus-rest-jackson") -implementation("quarkus-rest-client-jackson") +implementation("io.quarkus:quarkus-rest-client-jackson") ---- [NOTE] @@ -300,4 +300,4 @@ Learn more about virtual threads support on: - xref:./messaging-virtual-threads.adoc[@RunOnVirtualThread in messaging applications] (this guide covers Apache Kafka) - xref:./grpc-virtual-threads.adoc[@RunOnVirtualThread in gRPC services] -- xref:./virtual-threads.adoc[the virtual thread reference guide] (include native compilation and containerization) \ No newline at end of file +- xref:./virtual-threads.adoc[the virtual thread reference guide] (include native compilation and containerization) diff --git a/docs/src/main/asciidoc/security-authentication-mechanisms.adoc b/docs/src/main/asciidoc/security-authentication-mechanisms.adoc index 95a32393ed97b..f5cc1a250b9e0 100644 --- a/docs/src/main/asciidoc/security-authentication-mechanisms.adoc +++ b/docs/src/main/asciidoc/security-authentication-mechanisms.adoc @@ -602,7 +602,11 @@ quarkus.http.auth.inclusive=true If the authentication is inclusive then `SecurityIdentity` created by the first authentication mechanism can be injected into the application code. For example, if both <> and basic authentication mechanism authentications are required, -the <> authentication mechanism will create `SecurityIdentity` first. +the <> mechanism will create `SecurityIdentity` first. + +NOTE: The <> mechanism has the highest priority when inclusive authentication is enabled, to ensure +that an injected `SecurityIdentity` always represents <> and can be used to get access to `SecurityIdentity` +identities provided by other authentication mechanisms. Additional `SecurityIdentity` instances can be accessed as a `quarkus.security.identities` attribute on the first `SecurityIdentity`, however, accessing these extra identities directly may not be necessary, for example, diff --git a/docs/src/main/asciidoc/security-keycloak-authorization.adoc b/docs/src/main/asciidoc/security-keycloak-authorization.adoc index 275bace269635..2ddeb8377effb 100644 --- a/docs/src/main/asciidoc/security-keycloak-authorization.adoc +++ b/docs/src/main/asciidoc/security-keycloak-authorization.adoc @@ -1,39 +1,62 @@ //// -This guide is maintained in the main Quarkus repository -and pull requests should be submitted there: +This guide is maintained in the main Quarkus repository. +To contribute, submit a pull request here: https://github.com/quarkusio/quarkus/tree/main/docs/src/main/asciidoc //// = Using OpenID Connect (OIDC) and Keycloak to centralize authorization include::_attributes.adoc[] :diataxis-type: howto :categories: security -:keywords: sso oidc security keycloak -:topics: security,authentication,authorization,keycloak,sso,oidc -:extensions: io.quarkus:quarkus-oidc,io.quarkus:quarkus-keycloak-authorization +:keywords: sso, oidc, security, keycloak +:topics: security, authentication, authorization, keycloak, sso, oidc +:extensions: io.quarkus:quarkus-oidc, io.quarkus:quarkus-keycloak-authorization -Learn how to enable bearer token authorization in your Quarkus application using link:https://www.keycloak.org/docs/latest/authorization_services/index.html[Keycloak Authorization Services] for secure access to protected resources. +Learn how to enable bearer token authorization in your Quarkus application by using link:https://www.keycloak.org/docs/latest/authorization_services/index.html[Keycloak Authorization Services] for secure access to protected resources. -The `quarkus-keycloak-authorization` extension relies on `quarkus-oidc`. -It includes a policy enforcer that regulates access to secured resources. -Access is governed by permissions set in Keycloak. -Currently, this extension is compatible solely with Quarkus xref:security-oidc-bearer-token-authentication.adoc[OIDC service applications]. +== Overview -It provides a flexible and dynamic authorization capability based on Resource-Based Access Control. +The `quarkus-keycloak-authorization` extension builds on the `quarkus-oidc` extension to offer advanced authorization capabilities. It includes a policy enforcer that dynamically regulates access to secured resources. Access is governed by permissions defined in Keycloak, supporting flexible and dynamic Resource-Based Access Control (RBAC). -Rather than explicitly enforcing access through specific mechanisms such as role-based access control (RBAC), `quarkus-keycloak-authorization` determines request permissions based on resource attributes such as name, identifier, or Uniform Resource Identifier (URI). -This process involves sending a `quarkus-oidc`-verified bearer access token to Keycloak Authorization Services for an authorization decision. +Use the `quarkus-keycloak-authorization` extension only if you are using Keycloak and Keycloak Authorization Services is enabled in your environment to handle authorization decisions. -Use `quarkus-keycloak-authorization` only if you work with Keycloak and have Keycloak Authorization Services enabled to make authorization decisions. -Use `quarkus-oidc` if you do not work with Keycloak or work with Keycloak but do not have its Keycloak Authorization Services enabled to make authorization decisions. +If you are not using Keycloak, or if Keycloak is configured without Keycloak Authorization Services, use the `quarkus-oidc` extension instead. -By shifting authorization responsibilities outside your application, you enhance security through various access control methods while eliminating the need for frequent re-deployments whenever security needs evolve. -In this case, Keycloak acts as a centralized authorization hub, managing your protected resources and their corresponding permissions effectively. +.How it works -For more information, see the xref:security-oidc-bearer-token-authentication.adoc[OIDC Bearer token authentication] guide. -It is important to realize that the Bearer token authentication mechanism does the authentication and creates a security identity. -Meanwhile, the `quarkus-keycloak-authorization` extension applies a Keycloak Authorization Policy to this identity based on the current request path and other policy settings. +The `quarkus-keycloak-authorization` extension centralizes authorization responsibilities in Keycloak, enhancing security and simplifying application maintenance. The extension: -For more information, see https://www.keycloak.org/docs/latest/authorization_services/index.html#_enforcer_overview[Keycloak Authorization Services documentation]. +1. Uses the `quarkus-oidc` extension to verify bearer tokens. +2. Sends verified tokens to Keycloak Authorization Services. +3. Allows Keycloak to evaluate resource-based permissions dynamically, by using attributes such as resource name, identifier, or URI. + +By externalizing authorization decisions, you can: + +- Implement diverse access control strategies without modifying application code. +- Reduce redeployment needs as security requirements evolve. + +.Compatibility + +This extension is compatible only with Quarkus xref:security-oidc-bearer-token-authentication.adoc[OIDC service applications]. It complements explicit mechanisms such as role-based access control with dynamic authorization policies. + +.Key Features + +- **Centralized Management**: Delegate authorization decisions to Keycloak for consistent security policies across applications. +- **Dynamic Permissions**: Define access control dynamically by using resource attributes. +- **Simplified Maintenance**: Reduce the need to update and redeploy applications when access policies change. + +.Setting Up + +Before using this extension, ensure the following: + +1. Keycloak Authorization Services is enabled in your Keycloak instance. +2. Your Quarkus application includes the `quarkus-keycloak-authorization` extension. + +For detailed steps, see the xref:security-oidc-bearer-token-authentication.adoc[OIDC Bearer Token Authentication] guide. + +.Additional resources + +To learn more about Keycloak Authorization Services and the policy enforcer, visit the official documentation: +https://www.keycloak.org/docs/latest/authorization_services/index.html#_enforcer_overview[Keycloak Authorization Services Documentation]. == Prerequisites @@ -42,30 +65,55 @@ include::{includes}/prerequisites.adoc[] * https://stedolan.github.io/jq/[jq tool] * https://www.keycloak.org[Keycloak] + == Architecture -In this example, we build a very simple microservice that offers two endpoints: +This example demonstrates a simple microservice setup with two protected endpoints: * `/api/users/me` * `/api/admin` -These endpoints are protected. -Access is granted only when a client sends a bearer token with the request. -This token must be valid, having a correct signature, expiration date, and audience. -Additionally, the microservice must trust the token. +.Token-based access control + +Access to these endpoints is controlled by using bearer tokens. To gain access, the following conditions must be met: + +- **Valid token**: The token must have a correct signature, a valid expiration date, and the appropriate audience. +- **Trust**: The microservice must trust the issuing Keycloak server. -The bearer token is issued by a Keycloak server and represents the subject for which the token was issued. -For being an OAuth 2.0 Authorization Server, the token also references the client acting on behalf of the user. +The bearer tokens issued by the Keycloak server serve as: -The `/api/users/me` endpoint can be accessed by any user with a valid token. -As a response, it returns a JSON document with details about the user obtained from the information carried on the token. -This endpoint is protected with RBAC, and only users granted with the `user` role can access this endpoint. +- **User identifiers**: Indicating the subject (user) for whom the token was issued. +- **Client references**: Identifying the client application acting on behalf of the user, per OAuth 2.0 Authorization Server standards. -The `/api/admin` endpoint is protected with RBAC, and only users granted the `admin` role can access it. +.Endpoints and access policies -This is a very simple example of using RBAC policies to govern access to your resources. -However, Keycloak supports other policies that you can use to perform even more fine-grained access control. -By using this example, you'll see that your application is completely decoupled from your authorization policies, with enforcement purely based on the accessed resource. +For `/api/users/me`: + +- **Access policy**: Open to users with a valid bearer token and the `user` role. +- **Response**: Returns user details as a JSON object derived from the token. + +Example response: +[source,json] +---- +{ + "user": { + "id": "1234", + "username": "johndoe", + "email": "johndoe@example.com" + } +} +---- + +For `/api/admin`: + +- *Access policy*: Restricted to users with a valid bearer token and the `admin` role. + +.Decoupled authorization + +This example highlights the use of role-based access control (RBAC) policies to protect resources. Key points include: + +- *Policy flexibility*: Keycloak supports various policy types, such as attribute-based and custom policies, enabling fine-grained control. +- *Decoupled application logic*: Authorization policies are managed entirely by Keycloak, allowing your application to focus on its core functionality. == Solution @@ -78,23 +126,22 @@ The solution is in the `security-keycloak-authorization-quickstart` link:{quicks == Creating the project -First, we need a new project. -Create a new project with the following command: +To get started, create a new project by using the following command: :create-app-artifact-id: security-keycloak-authorization-quickstart :create-app-extensions: oidc,keycloak-authorization,rest-jackson include::{includes}/devtools/create-app.adoc[] -This command generates a project, importing the `keycloak-authorization` extension. -This extension implements a Keycloak Adapter for Quarkus applications and provides all the necessary capabilities to integrate with a Keycloak server and perform bearer token authorization. +This command generates a new project with the `keycloak-authorization` extension. The extension integrates a Keycloak Adapter into your Quarkus application, providing the necessary capabilities to interact with a Keycloak server and perform bearer token authorization. -If you already have your Quarkus project configured, you can add the `oidc` and `keycloak-authorization` extensions -to your project by running the following command in your project base directory: +.Adding extensions to an existing project + +If you already have an existing Quarkus project, you can add the `oidc` and `keycloak-authorization` extensions by running the following command in your project’s base directory: :add-extension-extensions: oidc,keycloak-authorization include::{includes}/devtools/extension-add.adoc[] -This adds the following dependencies to your build file: +This command adds the following dependencies to your build file: [source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] .pom.xml @@ -116,8 +163,9 @@ implementation("io.quarkus:quarkus-oidc") implementation("io.quarkus:quarkus-keycloak-authorization") ---- -Let's start by implementing the `/api/users/me` endpoint. -As you can see in the following source code, it is a regular Jakarta REST resource: +.Implementing the `/api/users/me` endpoint + +Start by implementing the `/api/users/me` endpoint. The following code defines a Jakarta REST resource that provides user details: [source,java] ---- @@ -159,7 +207,9 @@ public class UsersResource { } ---- -The source code for the `/api/admin` endpoint is also very simple: +.Implementing the `/api/admin` endpoint + +Next, define the `/api/admin` endpoint. The following code represents a simple Jakarta REST resource protected with authentication: [source,java] ---- @@ -184,49 +234,81 @@ public class AdminResource { } ---- -Be aware that we have not defined annotations such as `@RolesAllowed` to explicitly enforce access to a resource. -Instead, the extension is responsible for mapping the URIs of the protected resources in Keycloak and evaluating the permissions accordingly, granting or denying access depending on the permissions granted by Keycloak. +.Role-based access control with Keycloak + +Notice that explicit annotations such as `@RolesAllowed` are not defined to enforce access control for the resources. Instead, the `keycloak-authorization` extension dynamically maps the URIs of protected resources in Keycloak. + +Access control is managed as follows: -=== Configuring the application +- Keycloak evaluates permissions for each request based on its configured policies. +- The extension enforces these permissions, granting or denying access based on the roles or policies defined in Keycloak. -The OpenID Connect extension allows you to define the adapter configuration by using the `application.properties` file, which is usually located in the `src/main/resources` directory. +This decouples access control logic from the application code, making it easier to manage and update access policies directly in Keycloak. + +== Configuring the application + +You can use the OpenID Connect extension to configure the adapter settings through the `application.properties` file, typically located in the `src/main/resources` directory. Below is an example configuration: [source,properties] ---- # OIDC Configuration -%prod.quarkus.oidc.auth-server-url=https://localhost:8543/realms/quarkus -quarkus.oidc.client-id=backend-service -quarkus.oidc.credentials.secret=secret -quarkus.oidc.tls.verification=none +%prod.quarkus.oidc.auth-server-url=https://localhost:8543/realms/quarkus <1> +quarkus.oidc.client-id=backend-service <2> +quarkus.oidc.credentials.secret=secret <3> +quarkus.oidc.tls.verification=none <4> # Enable Policy Enforcement -quarkus.keycloak.policy-enforcer.enable=true +quarkus.keycloak.policy-enforcer.enable=true <5> -# Tell Dev Services for Keycloak to import the realm file -# This property is not effective when running the application in JVM or native modes -quarkus.keycloak.devservices.realm-path=quarkus-realm.json +# Import the realm file with Dev Services for Keycloak +# Note: This property is effective only in dev mode, not in JVM or native modes +quarkus.keycloak.devservices.realm-path=quarkus-realm.json <6> ---- +<1> Specifies the URL of the Keycloak server and the realm used for authentication. +<2> Identifies the client application within the Keycloak realm. +<3> Defines the client secret for authentication with the Keycloak server. +<4> Disables TLS verification for development purposes. Not recommended for production. +<5> Enables the Keycloak policy enforcer to manage access control based on defined permissions. +<6> Configures Dev Services to import a specified realm file, effective only in dev mode and not in JVM or native modes. -NOTE: Adding a `%prod.` profile prefix to `quarkus.oidc.auth-server-url` ensures that Dev Services for Keycloak launches a container for you when the application is run in dev mode. -For more information, see the <> section. +[NOTE] +==== +Adding the `%prod.` profile prefix to `quarkus.oidc.auth-server-url` ensures that Dev Services for Keycloak automatically launches a container in development mode. For more details, see the <> section. +==== -NOTE: By default, applications that use the `quarkus-oidc` extension are marked as a `service` type application (see `quarkus.oidc.application-type`). -This extension also supports only `web-app` type applications but only if the access token returned as part of the authorization code grant response is marked as a source of roles: `quarkus.oidc.roles.source=accesstoken` (`web-app` type applications check ID token roles by default). +[NOTE] +==== +By default, applications using the `quarkus-oidc` extension are treated as `service` type applications. However, the extension also supports `web-app` type applications under the following conditions: + +- The access token returned during the authorization code grant flow must be the source of roles (`quarkus.oidc.roles.source=accesstoken`). +- Note: For `web-app` type applications, ID token roles are checked by default. +==== == Starting and configuring the Keycloak server -NOTE: Do not start the Keycloak server when you run the application in dev mode. +[NOTE] +==== +Do not start the Keycloak server when you run the application in dev mode. Dev Services for Keycloak launches a container. For more information, see the <> section. +==== To start a Keycloak server, use the following Docker command: [source,bash,subs=attributes+] ---- -docker run --name keycloak -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin -p 8543:8443 -v "$(pwd)"/config/keycloak-keystore.jks:/etc/keycloak-keystore.jks quay.io/keycloak/keycloak:{keycloak.version} start --hostname-strict=false --https-key-store-file=/etc/keycloak-keystore.jks +docker run --name keycloak \ + -e KEYCLOAK_ADMIN=admin \ + -e KEYCLOAK_ADMIN_PASSWORD=admin \ + -p 8543:8443 \ + -v "$(pwd)"/config/keycloak-keystore.jks:/etc/keycloak-keystore.jks \ + quay.io/keycloak/keycloak:{keycloak.version} \ <1> + start --hostname-strict=false --https-key-store-file=/etc/keycloak-keystore.jks <2> ---- -where `keycloak.version` must be `25.0.6` or later and the `keycloak-keystore.jks` can be found in https://github.com/quarkusio/quarkus-quickstarts/blob/main/security-keycloak-authorization-quickstart/config/keycloak-keystore.jks[quarkus-quickstarts/security-keycloak-authorization-quickstart/config]. +<1> For `keycloak.version`, ensure the version is `25.0.6` or later. +<2> For Keycloak keystore, use the `keycloak-keystore.jks` file located at https://github.com/quarkusio/quarkus-quickstarts/blob/main/security-keycloak-authorization-quickstart/config/keycloak-keystore.jks[quarkus-quickstarts/security-keycloak-authorization-quickstart/config]. + Try to access your Keycloak server at https://localhost:8543[localhost:8543]. @@ -236,52 +318,83 @@ The username and password are both `admin`. Import the link:{quickstarts-tree-url}/security-keycloak-authorization-quickstart/config/quarkus-realm.json[realm configuration file] to create a new realm. For more details, see the Keycloak documentation about how to https://www.keycloak.org/docs/latest/server_admin/index.html#_create-realm[create a new realm]. -After importing the realm you can see the resource permissions: +After importing the realm, you can see the resource permissions: image::keycloak-authorization-permissions.png[alt=Keycloak Authorization Permissions,role="center"] It explains why the endpoint has no `@RolesAllowed` annotations - the resource access permissions are set directly in Keycloak. +.Accessing the Keycloak server + +. Open your browser and navigate to https://localhost:8543[https://localhost:8543]. +. Log in to the Keycloak Administration Console by using the following credentials: + - **Username**: `admin` + - **Password**: `admin` + +.Importing the realm configuration + +To create a new realm, import the link:{quickstarts-tree-url}/security-keycloak-authorization-quickstart/config/quarkus-realm.json[realm configuration file]. For detailed steps on creating realms, refer to the Keycloak documentation: https://www.keycloak.org/docs/latest/server_admin/index.html#_create-realm[Create a new realm]. + +After importing the realm, you can review the resource permissions: + +image::keycloak-authorization-permissions.png[alt=Keycloak Authorization Permissions,role="center"] + +.Role of Keycloak in resource permissions + +The resource access permissions are configured directly in Keycloak, which eliminates the need for `@RolesAllowed` annotations in your application code. This approach centralizes access control management within Keycloak, simplifying application maintenance and security updates. + [[keycloak-dev-mode]] == Running the application in dev mode -To run the application in dev mode, use: +To run the application in development mode, use the following command: include::{includes}/devtools/dev.adoc[] -xref:security-openid-connect-dev-services.adoc[Dev Services for Keycloak] launches a Keycloak container and imports a `quarkus-realm.json`. +xref:security-openid-connect-dev-services.adoc[Dev Services for Keycloak] starts a Keycloak container and imports the `quarkus-realm.json` configuration file. Open a xref:dev-ui.adoc[Dev UI] available at http://localhost:8080/q/dev-ui[/q/dev-ui] and click a `Provider: Keycloak` link in an `OpenID Connect` `Dev UI` card. -When asked to log in to a `Single Page Application` provided by `OpenID Connect Dev UI`: +.Interacting with Dev UI + +. Open the xref:dev-ui.adoc[Dev UI] at http://localhost:8080/q/dev-ui[/q/dev-ui]. +. Click the `Provider: Keycloak` link within the `OpenID Connect` Dev UI card. + +.Testing user permissions + +When prompted to log in to a `Single Page Application` provided by `OpenID Connect Dev UI`, do the following: + +. Log in as `alice` (password: `alice`), who only has a `User Permission` to access the `/api/users/me` resource: +.. Access `/api/admin`, which returns `403`. +.. Access `/api/users/me`, which returns `200`. +. Log out and log in as `admin` (password: `admin`), who has both `Admin Permission` to access the `/api/admin` resource and `User Permission` to access the `/api/users/me` resource: +.. Access `/api/admin`, which returns `200`. +.. Access `/api/users/me`, which returns `200`. + +.Customizing the Keycloak realm - * Log in as `alice` (password: `alice`), who only has a `User Permission` to access the `/api/users/me` resource: - ** Access `/api/admin`, which returns `403`. - ** Access `/api/users/me`, which returns `200`. - * Log out and log in as `admin` (password: `admin`), who has both `Admin Permission` to access the `/api/admin` resource and `User Permission` to access the `/api/users/me` resource: - ** Access `/api/admin`, which returns `200`. - ** Access `/api/users/me`, which returns `200`. +If you started Dev Services for Keycloak without importing a realm file such as link:{quickstarts-tree-url}/security-keycloak-authorization-quickstart/config/quarkus-realm.json[quarkus-realm.json], create a default `quarkus` realm without Keycloak authorization policies: -If you have started xref:security-openid-connect-dev-services.adoc[Dev Services for Keycloak] without importing a realm file such as link:{quickstarts-tree-url}/security-keycloak-authorization-quickstart/config/quarkus-realm.json[quarkus-realm.json] that is already configured to support Keycloak Authorization, create a default `quarkus` realm without Keycloak authorization policies. -In this case, you must select the `Keycloak Admin` link in the `OpenId Connect` Dev UI card and configure link:https://www.keycloak.org/docs/latest/authorization_services/index.html[Keycloak Authorization Services] in the default `quarkus` realm. +. Select the `Keycloak Admin` link from the `OpenID Connect` Dev UI card. +. Log in to the Keycloak admin console. The username and password are both `admin`. +. Follow the instructions at link:https://www.keycloak.org/docs/latest/authorization_services/index.html[Keycloak Authorization Services documentation] to enable authorization policies in the `quarkus` realm. The `Keycloak Admin` link is easy to find in Dev UI: image::dev-ui-oidc-keycloak-card.png[alt=Dev UI OpenID Connect Card,role="center"] -When logging into the Keycloak admin console, the username and password are both `admin`. +.Adding custom JavaScript policies -If your application uses Keycloak authorization configured with link:https://www.keycloak.org/docs/latest/authorization_services/index.html#_policy_js[JavaScript policies] that are deployed in a JAR file, you can set up Dev Services for Keycloak to transfer this archive to the Keycloak container. -For instance: +If your application uses Keycloak authorization configured with link:https://www.keycloak.org/docs/latest/authorization_services/index.html#_policy_js[JavaScript policies] that are deployed in a JAR archive, Dev Services for Keycloak can transfer this archive to the Keycloak container. Use the following properties in `application.properties` to configure the transfer: [source,properties] ---- +# Alias the policies archive quarkus.keycloak.devservices.resource-aliases.policies=/policies.jar <1> +# Map the policies archive to a specific location in the container quarkus.keycloak.devservices.resource-mappings.policies=/opt/keycloak/providers/policies.jar <2> ---- -<1> `policies` alias is created for the `/policies.jar` classpath resource. -Policy archive can also be located in the file system. -<2> The policies archive is mapped to the `/opt/keycloak/providers/policies.jar` container location. +<1> Creates a `policies` alias for the `/policies.jar` classpath resource. The policies archive can also be located on the file system. +<2> Maps the policies archive to the `/opt/keycloak/providers/policies.jar` location inside the Keycloak container. == Running the application in JVM mode diff --git a/docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc b/docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc index a91d02726b3a3..bc2a4fcecd343 100644 --- a/docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc @@ -1346,6 +1346,49 @@ Authentication that requires a dynamic tenant will fail. You can filter OIDC requests made by Quarkus to the OIDC provider by registering one or more `OidcRequestFilter` implementations, which can update or add new request headers, and log requests. For more information, see xref:security-oidc-code-flow-authentication#code-flow-oidc-request-filters[OIDC request filters]. +[[bearer-token-oidc-response-filters]] +=== OIDC response filters + +You can filter responses from the OIDC providers by registering one or more `OidcResponseFilter` implementations, which can check the response status, headers and body in order to log them or perform other actions. + +You can have a single filter intercepting all the OIDC responses, or use an `@OidcEndpoint` annotation to apply this filter to the specific endpoint responses only. For example: + +[source,java] +---- +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.Unremovable; +import io.quarkus.logging.Log; +import io.quarkus.oidc.common.OidcEndpoint; +import io.quarkus.oidc.common.OidcEndpoint.Type; +import io.quarkus.oidc.common.OidcResponseFilter; +import io.quarkus.oidc.common.runtime.OidcConstants; +import io.quarkus.oidc.runtime.OidcUtils; + +@ApplicationScoped +@Unremovable +@OidcEndpoint(value = Type.DISCOVERY) <1> +public class DiscoveryEndpointResponseFilter implements OidcResponseFilter { + + @Override + public void filter(OidcResponseContext rc) { + String contentType = rc.responseHeaders().get("Content-Type"); <2> + if (contentType.equals("application/json") { + String tenantId = rc.requestProperties().get(OidcUtils.TENANT_ID_ATTRIBUTE); <3> + String metadata = rc.responseBody().toString(); <4> + Log.debugf("Tenant %s OIDC metadata: %s", tenantId, metadata); + } + } +} + +---- +<1> Restrict this filter to requests targeting the OIDC discovery endpoint only. +<2> Check the response `Content-Type` header. +<3> Use `OidcRequestContextProperties` request properties to get the tenant id. +<4> Get the response data as String. + == References * xref:security-oidc-configuration-properties-reference.adoc[OIDC configuration properties] diff --git a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc index b20907196ff14..7c656133d0329 100644 --- a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc @@ -207,6 +207,18 @@ quarkus.oidc.credentials.jwt.key-id=mykeyAlias Using `client_secret_jwt` or `private_key_jwt` authentication methods ensures that a client secret does not get sent to the OIDC provider, therefore avoiding the risk of a secret being intercepted by a 'man-in-the-middle' attack. +.Example how JWT Bearer token can be used to authenticate client + +[source,properties] +---- +quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus/ +quarkus.oidc.client-id=quarkus-app +quarkus.oidc.credentials.jwt.source=bearer <1> +quarkus.oidc.credentials.jwt.token-path=/var/run/secrets/tokens <2> +---- +<1> Use JWT bearer token to authenticate OIDC provider client, see the link:https://www.rfc-editor.org/rfc/rfc7523#section-2.2[Using JWTs for Client Authentication] section for more information. +<2> Path to a JWT bearer token. Quarkus loads a new token from a filesystem and reloads it when the token has expired. + ==== Additional JWT authentication options If `client_secret_jwt`, `private_key_jwt`, or an Apple `post_jwt` authentication methods are used, then you can customize the JWT signature algorithm, key identifier, audience, subject and issuer. @@ -392,9 +404,8 @@ package io.quarkus.it.keycloak; import jakarta.enterprise.context.ApplicationScoped; -import org.jboss.logging.Logger; - import io.quarkus.arc.Unremovable; +import io.quarkus.logging.Log; import io.quarkus.oidc.common.OidcEndpoint; import io.quarkus.oidc.common.OidcEndpoint.Type; import io.quarkus.oidc.common.OidcResponseFilter; @@ -405,8 +416,7 @@ import io.quarkus.oidc.runtime.OidcUtils; @Unremovable @OidcEndpoint(value = Type.TOKEN) <1> public class TokenEndpointResponseFilter implements OidcResponseFilter { - private static final Logger LOG = Logger.getLogger(TokenResponseFilter.class); - + @Override public void filter(OidcResponseContext rc) { String contentType = rc.responseHeaders().get("Content-Type"); <2> @@ -414,7 +424,7 @@ public class TokenEndpointResponseFilter implements OidcResponseFilter { && OidcConstants.AUTHORIZATION_CODE.equals(rc.requestProperties().get(OidcConstants.GRANT_TYPE)) <3> && "code-flow-user-info-cached-in-idtoken".equals(rc.requestProperties().get(OidcUtils.TENANT_ID_ATTRIBUTE)) <3> && rc.responseBody().toJsonObject().containsKey("id_token")) { <4> - LOG.debug("Authorization code completed for tenant 'code-flow-user-info-cached-in-idtoken'"); + Log.debug("Authorization code completed for tenant 'code-flow-user-info-cached-in-idtoken'"); } } } diff --git a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc index 1f82509661f6b..41387d39bc78e 100644 --- a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc @@ -878,7 +878,17 @@ quarkus.oidc-client.credentials.jwt.source=bearer Next, the JWT bearer token must be provided as a `client_assertion` parameter to the OIDC client. -You can use `OidcClient` methods for acquiring or refreshing tokens which accept additional grant parameters, for example, `oidcClient.getTokens(Map.of("client_assertion", "ey..."))`. +Quarkus can load the JWT bearer token from a file system. +For example, in Kubernetes, a service account token projection can be mounted to a `/var/run/secrets/tokens` path. +Then all you need to do is configure a JWT bearer token path as follows: + +[source,properties] +---- +quarkus.oidc-client.credentials.jwt.token-path=/var/run/secrets/tokens <1> +---- +<1> Path to a JWT bearer token. Quarkus loads a new token from a filesystem and reload it when the token has expired. + +Your other option is to use `OidcClient` methods for acquiring or refreshing tokens which accept additional grant parameters, for example, `oidcClient.getTokens(Map.of("client_assertion", "ey..."))`. If you work work with the OIDC client filters then you must register a custom filter which will provide this assertion. diff --git a/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc b/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc index a7efa83699346..68ae114f911d4 100644 --- a/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc @@ -332,6 +332,7 @@ A similar technique can be used with `TenantConfigResolver`, where a `tenant-id` ==== If you also use Hibernate ORM multitenancy or MongoDB with Panache multitenancy and both tenant ids are the same, you can get the tenant id from the `RoutingContext` attribute with `tenant-id`. You can find more information here: + * xref:hibernate-orm.adoc#multitenancy[Hibernate ORM multitenancy] * xref:mongodb-panache.adoc#multitenancy[MongoDB with Panache multitenancy] ==== diff --git a/docs/src/main/asciidoc/security-openid-connect-providers.adoc b/docs/src/main/asciidoc/security-openid-connect-providers.adoc index 6f43935a6c57a..240922f2a9249 100644 --- a/docs/src/main/asciidoc/security-openid-connect-providers.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-providers.adoc @@ -32,7 +32,7 @@ This property can be used in `application.properties`, in xref:security-openid-c [[apple]] === Apple -In order to set up OIDC for Apple you need to create a developer account, and sign up for the 99€/year program, but you cannot test your application on `localhost` like most other OIDC providers: +In order to set up OIDC for Apple you need to create a developer account, and sign up for the 99$/year program, but you cannot test your application on `localhost` like most other OIDC providers: you will need to run it over HTTPS and make it publicly accessible, so for development purposes you may want to use a service such as https://ngrok.com. @@ -84,7 +84,7 @@ Enable `Sign in with Apple` and press `Configure`: image::oidc-apple-12.png[role="thumb"] -Add your domain and return URL (set to `/_renarde/security/oidc-success`) and press `Next`: +Add your domain and return URL (set to `/apple`) and press `Next`: image::oidc-apple-13.png[role="thumb"] diff --git a/docs/src/main/asciidoc/security-webauthn.adoc b/docs/src/main/asciidoc/security-webauthn.adoc index 996691c7a8183..ee8c82686c94c 100644 --- a/docs/src/main/asciidoc/security-webauthn.adoc +++ b/docs/src/main/asciidoc/security-webauthn.adoc @@ -10,6 +10,8 @@ include::_attributes.adoc[] :categories: security :topics: security,webauthn,authorization :extensions: io.quarkus:quarkus-security-webauthn +:webauthn-api: https://javadoc.io/doc/io.quarkus/quarkus-security-webauthn/{quarkus-version} +:webauthn-test-api: https://javadoc.io/doc/io.quarkus/quarkus-test-security-webauthn/{quarkus-version} This guide demonstrates how your Quarkus application can use WebAuthn authentication instead of passwords. @@ -221,7 +223,7 @@ public class UserResource { === Storing our WebAuthn credentials -We can now describe how our WebAuthn credentials are stored in our database with three entities. Note that we've +We can now describe how our WebAuthn credentials are stored in our database with two entities. Note that we've simplified the model in order to only store one credential per user (who could actually have more than one WebAuthn credential and other data such as roles): @@ -229,139 +231,65 @@ and other data such as roles): ---- package org.acme.security.webauthn; -import java.util.ArrayList; import java.util.List; +import java.util.UUID; +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord.RequiredPersistedData; import jakarta.persistence.Entity; -import jakarta.persistence.OneToMany; +import jakarta.persistence.Id; import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; - -import io.quarkus.hibernate.orm.panache.PanacheEntity; -import io.vertx.ext.auth.webauthn.Authenticator; -import io.vertx.ext.auth.webauthn.PublicKeyCredential; -@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"userName", "credID"})) @Entity -public class WebAuthnCredential extends PanacheEntity { - - /** - * The username linked to this authenticator - */ - public String userName; - - /** - * The type of key (must be "public-key") - */ - public String type = "public-key"; - - /** - * The non user identifiable id for the authenticator - */ - public String credID; - - /** - * The public key associated with this authenticator - */ - public String publicKey; - - /** - * The signature counter of the authenticator to prevent replay attacks - */ - public long counter; +public class WebAuthnCredential extends PanacheEntityBase { + + @Id + public String credentialId; - public String aaguid; - - /** - * The Authenticator attestation certificates object, a JSON like: - *

{@code
-     *   {
-     *     "alg": "string",
-     *     "x5c": [
-     *       "base64"
-     *     ]
-     *   }
-     * }
- */ - /** - * The algorithm used for the public credential - */ - public PublicKeyCredential alg; - - /** - * The list of X509 certificates encoded as base64url. - */ - @OneToMany(mappedBy = "webAuthnCredential") - public List x5c = new ArrayList<>(); - - public String fmt; - - // owning side + public byte[] publicKey; + public long publicKeyAlgorithm; + public long counter; + public UUID aaguid; + + // this is the owning side @OneToOne public User user; public WebAuthnCredential() { } - - public WebAuthnCredential(Authenticator authenticator, User user) { - aaguid = authenticator.getAaguid(); - if(authenticator.getAttestationCertificates() != null) - alg = authenticator.getAttestationCertificates().getAlg(); - counter = authenticator.getCounter(); - credID = authenticator.getCredID(); - fmt = authenticator.getFmt(); - publicKey = authenticator.getPublicKey(); - type = authenticator.getType(); - userName = authenticator.getUserName(); - if(authenticator.getAttestationCertificates() != null - && authenticator.getAttestationCertificates().getX5c() != null) { - for (String x5c : authenticator.getAttestationCertificates().getX5c()) { - WebAuthnCertificate cert = new WebAuthnCertificate(); - cert.x5c = x5c; - cert.webAuthnCredential = this; - this.x5c.add(cert); - } - } + + public WebAuthnCredential(WebAuthnCredentialRecord credentialRecord, User user) { + RequiredPersistedData requiredPersistedData = + credentialRecord.getRequiredPersistedData(); + aaguid = requiredPersistedData.aaguid(); + counter = requiredPersistedData.counter(); + credentialId = requiredPersistedData.credentialId(); + publicKey = requiredPersistedData.publicKey(); + publicKeyAlgorithm = requiredPersistedData.publicKeyAlgorithm(); this.user = user; user.webAuthnCredential = this; } - public static List findByUserName(String userName) { - return list("userName", userName); + public WebAuthnCredentialRecord toWebAuthnCredentialRecord() { + return WebAuthnCredentialRecord + .fromRequiredPersistedData( + new RequiredPersistedData(user.username, credentialId, + aaguid, publicKey, + publicKeyAlgorithm, counter)); } - public static List findByCredID(String credID) { - return list("credID", credID); + public static List findByUsername(String username) { + return list("user.username", username); + } + + public static WebAuthnCredential findByCredentialId(String credentialId) { + return findById(credentialId); } } ---- -We also need a second entity for the credentials: - -[source,java] ----- -package org.acme.security.webauthn; - -import io.quarkus.hibernate.orm.panache.PanacheEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.ManyToOne; - - -@Entity -public class WebAuthnCertificate extends PanacheEntity { - - @ManyToOne - public WebAuthnCredential webAuthnCredential; - - /** - * The list of X509 certificates encoded as base64url. - */ - public String x5c; -} ----- - -And last but not least, our user entity: +And our user entity: [source,java] ---- @@ -378,112 +306,88 @@ import jakarta.persistence.Table; public class User extends PanacheEntity { @Column(unique = true) - public String userName; + public String username; // non-owning side, so we can add more credentials later @OneToOne(mappedBy = "user") public WebAuthnCredential webAuthnCredential; - public static User findByUserName(String userName) { - return User.find("userName", userName).firstResult(); + public static User findByUsername(String username) { + return User.find("username", username).firstResult(); } } ---- ==== A note about usernames and credential IDs -WebAuthn relies on a combination of usernames (unique per user) and credential IDs (unique per authenticator device). - -The reasons why there are two such identifiers, and why they are not unique keys for the credentials themselves are: +Usernames are unique and to your users. Every created WebAuthn credential record has a unique ID. -- A single user can have more than one authenticator device, which means a single username can map to multiple credential IDs, - all of which identify the same user. -- An authenticator device may be shared by multiple users, because a single person may want multiple user accounts with different - usernames, all of which having the same authenticator device. So a single credential ID may be used by multiple different users. - -The combination of username and credential ID should be a unicity constraint for your credentials table, though. +You can allow (if you want, but you don't have to) your users to have more than one authenticator device, +which means a single username can map to multiple credential IDs, all of which identify the same user. === Exposing your entities to Quarkus WebAuthn -You need to define a bean implementing the `WebAuthnUserProvider` in order to allow the Quarkus WebAuthn +You need to define a bean implementing the link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`] in order to allow the Quarkus WebAuthn extension to load and store credentials. This is where you tell Quarkus how to turn your data model into the WebAuthn security model: [source,java] ---- -package org.acme.security.webauthn; - import java.util.Collections; import java.util.List; import java.util.Set; -import java.util.stream.Collectors; - -import io.smallrye.common.annotation.Blocking; -import jakarta.enterprise.context.ApplicationScoped; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.smallrye.common.annotation.Blocking; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.AttestationCertificates; -import io.vertx.ext.auth.webauthn.Authenticator; +import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; -import static org.acme.security.webauthn.WebAuthnCredential.findByCredID; -import static org.acme.security.webauthn.WebAuthnCredential.findByUserName; - @Blocking @ApplicationScoped public class MyWebAuthnSetup implements WebAuthnUserProvider { @Transactional @Override - public Uni> findWebAuthnCredentialsByUserName(String userName) { - return Uni.createFrom().item(toAuthenticators(findByUserName(userName))); + public Uni> findByUsername(String userId) { + return Uni.createFrom().item( + WebAuthnCredential.findByUsername(userId) + .stream() + .map(WebAuthnCredential::toWebAuthnCredentialRecord) + .toList()); } @Transactional @Override - public Uni> findWebAuthnCredentialsByCredID(String credID) { - return Uni.createFrom().item(toAuthenticators(findByCredID(credID))); + public Uni findByCredentialId(String credId) { + WebAuthnCredential creds = WebAuthnCredential.findByCredentialId(credId); + if(creds == null) + return Uni.createFrom() + .failure(new RuntimeException("No such credential ID")); + return Uni.createFrom().item(creds.toWebAuthnCredentialRecord()); } @Transactional @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { - // leave the scooby user to the manual endpoint, because if we do it here it will be created/updated twice - if(!authenticator.getUserName().equals("scooby")) { - User user = User.findByUserName(authenticator.getUserName()); - if(user == null) { - // new user - User newUser = new User(); - newUser.userName = authenticator.getUserName(); - WebAuthnCredential credential = new WebAuthnCredential(authenticator, newUser); - credential.persist(); - newUser.persist(); - } else { - // existing user - user.webAuthnCredential.counter = authenticator.getCounter(); - } - } - return Uni.createFrom().nullItem(); - } - - private static List toAuthenticators(List dbs) { - return dbs.stream().map(MyWebAuthnSetup::toAuthenticator).collect(Collectors.toList()); + public Uni store(WebAuthnCredentialRecord credentialRecord) { + User newUser = new User(); + // We can only store one credential per username thanks to the unicity constraint + // which will cause this transaction to fail and throw if the username already exists + newUser.username = credentialRecord.getUsername(); + WebAuthnCredential credential = new WebAuthnCredential(credentialRecord, newUser); + credential.persist(); + newUser.persist(); + return Uni.createFrom().voidItem(); } - private static Authenticator toAuthenticator(WebAuthnCredential credential) { - Authenticator ret = new Authenticator(); - ret.setAaguid(credential.aaguid); - AttestationCertificates attestationCertificates = new AttestationCertificates(); - attestationCertificates.setAlg(credential.alg); - ret.setAttestationCertificates(attestationCertificates); - ret.setCounter(credential.counter); - ret.setCredID(credential.credID); - ret.setFmt(credential.fmt); - ret.setPublicKey(credential.publicKey); - ret.setType(credential.type); - ret.setUserName(credential.userName); - return ret; + @Transactional + @Override + public Uni update(String credentialId, long counter) { + WebAuthnCredential credential = + WebAuthnCredential.findByCredentialId(credentialId); + credential.counter = counter; + return Uni.createFrom().voidItem(); } @Override @@ -496,6 +400,25 @@ public class MyWebAuthnSetup implements WebAuthnUserProvider { } ---- +Warning: When implementing your own `WebAuthnUserProvider.store` method, make sure that you never allow creating +new credentials for a `username` that already exists. Otherwise you risk allowing third-parties to impersonate existing +users by letting them add their own credentials to existing accounts. If you want to allow existing users to register +more than one WebAuthn credential, you must make sure in `WebAuthnUserProvider.store` that the user is currently logged +in under the same `username` to which you want to add new credentials. In every other case, make sure to return a failed +`Uni` from this method. In this particular example, this is checked using a unicity constraint on the user name, which +will cause the transaction to fail if the user already exists. + +== Configuration + +Because we want to delegate login and registration to the default Quarkus WebAuthn endpoints, we need to enable them +in configuration (`src/main/resources/application.properties`): + +[source,properties] +---- +quarkus.webauthn.enable-login-endpoint=true +quarkus.webauthn.enable-registration-endpoint=true +---- + == Writing the HTML application We now need to write a web page with links to all our APIs, as well as a way to register a new user, login, and logout, @@ -563,14 +486,13 @@ in `src/main/resources/META-INF/resources/index.html`:

Login

-

Register

-
+


@@ -578,26 +500,23 @@ in `src/main/resources/META-INF/resources/index.html`:

+ +---- + +Or, if you need to customise the endpoints: + [source,javascript] ---- ---- +=== CSRF considerations + +If you use the endpoints provided by Quarkus, they will not be protected by xdoc:security-csrf-prevention.adoc[CSRF], but +if you define your own endpoints and use this JavaScript library to access them you will need to configure CSRF via headers: + +[source,javascript] +---- + + +---- + === Invoke registration -The `webAuthn.register` method invokes the registration challenge endpoint, then calls the authenticator and invokes the callback endpoint +The `webAuthn.register` method invokes the registration challenge endpoint, then calls the authenticator and invokes the registration endpoint for that registration, and returns a https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise[Promise object]: [source,javascript] ---- -webAuthn.register({ name: userName, displayName: firstName + " " + lastName }) +webAuthn.register({ username: username, displayName: firstName + " " + lastName }) .then(body => { // do something now that the user is registered }) @@ -847,12 +857,12 @@ webAuthn.register({ name: userName, displayName: firstName + " " + lastName }) === Invoke login -The `webAuthn.login` method invokes the login challenge endpoint, then calls the authenticator and invokes the callback endpoint +The `webAuthn.login` method invokes the login challenge endpoint, then calls the authenticator and invokes the login endpoint for that login, and returns a https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise[Promise object]: [source,javascript] ---- -webAuthn.login({ name: userName }) +webAuthn.login({ username: username }) <1> .then(body => { // do something now that the user is logged in }) @@ -861,16 +871,18 @@ webAuthn.login({ name: userName }) }); ---- +<1> The username is optional, in the case of https://www.w3.org/TR/webauthn-3/#discoverable-credential[Discoverable Credentials] (with PassKeys) + === Only invoke the registration challenge and authenticator -The `webAuthn.registerOnly` method invokes the registration challenge endpoint, then calls the authenticator and returns +The `webAuthn.registerClientSteps` method invokes the registration challenge endpoint, then calls the authenticator and returns a https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise[Promise object] containing a -JSON object suitable for being sent to the callback endpoint. You can use that JSON object in order to store the credentials +JSON object suitable for being sent to the registration endpoint. You can use that JSON object in order to store the credentials in hidden form `input` elements, for example, and send it as part of a regular HTML form: [source,javascript] ---- -webAuthn.registerOnly({ name: userName, displayName: firstName + " " + lastName }) +webAuthn.registerClientSteps({ username: username, displayName: firstName + " " + lastName }) .then(body => { // store the registration JSON in form elements document.getElementById('webAuthnId').value = body.id; @@ -886,14 +898,14 @@ webAuthn.registerOnly({ name: userName, displayName: firstName + " " + lastName === Only invoke the login challenge and authenticator -The `webAuthn.loginOnly` method invokes the login challenge endpoint, then calls the authenticator and returns +The `webAuthn.loginClientSteps` method invokes the login challenge endpoint, then calls the authenticator and returns a https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise[Promise object] containing a -JSON object suitable for being sent to the callback endpoint. You can use that JSON object in order to store the credentials +JSON object suitable for being sent to the login endpoint. You can use that JSON object in order to store the credentials in hidden form `input` elements, for example, and send it as part of a regular HTML form: [source,javascript] ---- -webAuthn.loginOnly({ name: userName }) +webAuthn.loginClientSteps({ username: username }) <1> .then(body => { // store the login JSON in form elements document.getElementById('webAuthnId').value = body.id; @@ -909,25 +921,95 @@ webAuthn.loginOnly({ name: userName }) }); ---- +<1> The username is optional, in the case of https://www.w3.org/TR/webauthn-3/#discoverable-credential[Discoverable Credentials] (with PassKeys) + == Handling login and registration endpoints yourself Sometimes, you will want to ask for more data than just a username in order to register a user, -or you want to deal with login and registration with custom validation, and so the WebAuthn callback -endpoint is not enough. +or you want to deal with login and registration with custom validation, and so the default WebAuthn login +and registration endpoints are not enough. -In this case, you can use the `WebAuthn.loginOnly` and `WebAuthn.registerOnly` methods from the JavaScript +In this case, you can use the `WebAuthn.loginClientSteps` and `WebAuthn.registerClientSteps` methods from the JavaScript library, store the authenticator data in hidden form elements, and send them as part of your form payload to the server to your custom login or registration endpoints. -If you are storing them in form input elements, you can then use the `WebAuthnLoginResponse` and -`WebAuthnRegistrationResponse` classes, mark them as `@BeanParam` and then use the `WebAuthnSecurity.login` -and `WebAuthnSecurity.register` methods to replace the `/q/webauthn/callback` endpoint. This even -allows you to create two separate endpoints for handling login and registration at different endpoints. +If you are storing them in form input elements, you can then use the link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnLoginResponse.html[`WebAuthnLoginResponse`] and +link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnRegistrationResponse.html[`WebAuthnRegistrationResponse`] classes, +mark them as `@BeanParam` and then use the +link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html#login(io.quarkus.security.webauthn.WebAuthnLoginResponse,io.vertx.ext.web.RoutingContext)[`WebAuthnSecurity.login`] +and link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html#register(io.quarkus.security.webauthn.WebAuthnRegisterResponse,io.vertx.ext.web.RoutingContext)[`WebAuthnSecurity.register`] +methods to replace the `/q/webauthn/login` and `/q/webauthn/register` endpoints. -In most cases you can keep using the `/q/webauthn/login` and `/q/webauthn/register` challenge-initiating +In most cases you can keep using the `/q/webauthn/login-options-challenge` and `/q/webauthn/register-options-challenge` challenge-initiating endpoints, because this is not where custom logic is required. -For example, here's how you can handle a custom login and register action: +In this case, the registration flow is a little different because you will write your own registration endpoint +which will handle storing of the credentials and setting up the session cookie: + +image::webauthn-custom-register.svg[role="thumb"] + +Similarly, the login flow is a little different because you will write your own login endpoint +which will handle updating the credentials and setting up the session cookie: + +image::webauthn-custom-login.svg[role="thumb"] + +If you handle user and credential creation and logins yourself in your endpoints, you only need +to provide a read-only view of your entities in your link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`], so you can skip +the `store` and `update` methods: + +[source,java] +---- +package org.acme.security.webauthn; + +import java.util.List; + +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; +import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.smallrye.common.annotation.Blocking; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import model.WebAuthnCredential; + +@Blocking +@ApplicationScoped +public class MyWebAuthnSetup implements WebAuthnUserProvider { + + @Transactional + @Override + public Uni> findByUsername(String username) { + return Uni.createFrom().item( + WebAuthnCredential.findByUsername(username) + .stream() + .map(WebAuthnCredential::toWebAuthnCredentialRecord) + .toList()); + } + + @Transactional + @Override + public Uni findByCredentialId(String credentialId) { + WebAuthnCredential creds = WebAuthnCredential.findByCredentialId(credentialId); + if(creds == null) + return Uni.createFrom() + .failure(new RuntimeException("No such credential ID")); + return Uni.createFrom().item(creds.toWebAuthnCredentialRecord()); + } + + @Override + public Set getRoles(String userId) { + if(userId.equals("admin")) { + return Set.of("user", "admin"); + } + return Collections.singleton("user"); + } +} +---- + +NOTE: When setting up your own login and registration endpoints, you don't need to enable the default endpoints, so you can +remove the `quarkus.webauthn.enable-login-endpoint` and `quarkus.webauthn.enable-registration-endpoint` configuration. + +Thankfully, you can use the link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html[`WebAuthnSecurity`] bean to handle the WebAuthn-specific part of +your registration and login endpoints, and focus on your own logic: [source,java] ---- @@ -943,10 +1025,10 @@ import jakarta.ws.rs.core.Response.Status; import org.jboss.resteasy.reactive.RestForm; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnLoginResponse; import io.quarkus.security.webauthn.WebAuthnRegisterResponse; import io.quarkus.security.webauthn.WebAuthnSecurity; -import io.vertx.ext.auth.webauthn.Authenticator; import io.vertx.ext.web.RoutingContext; @Path("") @@ -955,29 +1037,28 @@ public class LoginResource { @Inject WebAuthnSecurity webAuthnSecurity; - // Provide an alternative implementation of the /q/webauthn/callback endpoint, only for login + // Provide an alternative implementation of the /q/webauthn/login endpoint @Path("/login") @POST @Transactional - public Response login(@RestForm String userName, - @BeanParam WebAuthnLoginResponse webAuthnResponse, + public Response login(@BeanParam WebAuthnLoginResponse webAuthnResponse, RoutingContext ctx) { // Input validation - if(userName == null || userName.isEmpty() || !webAuthnResponse.isSet() || !webAuthnResponse.isValid()) { + if(!webAuthnResponse.isSet() || !webAuthnResponse.isValid()) { return Response.status(Status.BAD_REQUEST).build(); } - User user = User.findByUserName(userName); - if(user == null) { - // Invalid user - return Response.status(Status.BAD_REQUEST).build(); - } try { - Authenticator authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx).await().indefinitely(); + WebAuthnCredentialRecord credentialRecord = this.webAuthnSecurity.login(webAuthnResponse, ctx).await().indefinitely(); + User user = User.findByUsername(credentialRecord.getUsername()); + if(user == null) { + // Invalid user + return Response.status(Status.BAD_REQUEST).build(); + } // bump the auth counter - user.webAuthnCredential.counter = authenticator.getCounter(); + user.webAuthnCredential.counter = credentialRecord.getCounter(); // make a login cookie - this.webAuthnSecurity.rememberUser(authenticator.getUserName(), ctx); + this.webAuthnSecurity.rememberUser(credentialRecord.getUsername(), ctx); return Response.ok().build(); } catch (Exception exception) { // handle login failure - make a proper error response @@ -985,33 +1066,36 @@ public class LoginResource { } } - // Provide an alternative implementation of the /q/webauthn/callback endpoint, only for registration + // Provide an alternative implementation of the /q/webauthn/register endpoint @Path("/register") @POST @Transactional - public Response register(@RestForm String userName, + public Response register(@RestForm String username, @BeanParam WebAuthnRegisterResponse webAuthnResponse, RoutingContext ctx) { // Input validation - if(userName == null || userName.isEmpty() || !webAuthnResponse.isSet() || !webAuthnResponse.isValid()) { + if(username == null || username.isEmpty() + || !webAuthnResponse.isSet() || !webAuthnResponse.isValid()) { return Response.status(Status.BAD_REQUEST).build(); } - User user = User.findByUserName(userName); + User user = User.findByUsername(username); if(user != null) { // Duplicate user return Response.status(Status.BAD_REQUEST).build(); } try { // store the user - Authenticator authenticator = this.webAuthnSecurity.register(webAuthnResponse, ctx).await().indefinitely(); + WebAuthnCredentialRecord credentialRecord = + webAuthnSecurity.register(username, webAuthnResponse, ctx).await().indefinitely(); User newUser = new User(); - newUser.userName = authenticator.getUserName(); - WebAuthnCredential credential = new WebAuthnCredential(authenticator, newUser); + newUser.username = credentialRecord.getUsername(); + WebAuthnCredential credential = + new WebAuthnCredential(credentialRecord, newUser); credential.persist(); newUser.persist(); // make a login cookie - this.webAuthnSecurity.rememberUser(newUser.userName, ctx); + this.webAuthnSecurity.rememberUser(newUser.username, ctx); return Response.ok().build(); } catch (Exception ignored) { // handle login failure @@ -1022,28 +1106,32 @@ public class LoginResource { } ---- -NOTE: The `WebAuthnSecurity` methods do not set or read the user cookie, so you will have to take care +NOTE: The link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html[`WebAuthnSecurity`] +methods do not set or read the <>, so you will have to take care of it yourself, but it allows you to use other means of storing the user, such as JWT. You can use the -`rememberUser(String userName, RoutingContext ctx)` and `logout(RoutingContext ctx)` methods on the same -`WebAuthnSecurity` class if you want to manually set up login cookies. +link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html#rememberUser(java.lang.String,io.vertx.ext.web.RoutingContext)[`WebAuthnSecurity.rememberUser`] + and link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html#logout(io.vertx.ext.web.RoutingContext)[`WebAuthnSecurity.logout`] + methods on the same link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html[`WebAuthnSecurity`] class if you want to manually set up login cookies. == Blocking version -If you're using a blocking data access to the database, you can safely block on the `WebAuthnSecurity` methods, +If you're using a blocking data access to the database, you can safely block on the +link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html[`WebAuthnSecurity`] methods, with `.await().indefinitely()`, because nothing is async in the `register` and `login` methods, besides the -data access with your `WebAuthnUserProvider`. +data access with your link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`]. -You will have to add the `@Blocking` annotation on your `WebAuthnUserProvider` class in order to tell the +You will have to add the `@Blocking` annotation on your link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`] class in order for the Quarkus WebAuthn endpoints to defer those calls to the worker pool. == Virtual-Threads version -If you're using a blocking data access to the database, you can safely block on the `WebAuthnSecurity` methods, +If you're using a blocking data access to the database, you can safely block on the +link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html[`WebAuthnSecurity`] methods, with `.await().indefinitely()`, because nothing is async in the `register` and `login` methods, besides the -data access with your `WebAuthnUserProvider`. +data access with your link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`]. -You will have to add the `@RunOnVirtualThread` annotation on your `WebAuthnUserProvider` class in order to tell the -Quarkus WebAuthn endpoints to defer those calls to virtual threads. +You will have to add the `@RunOnVirtualThread` annotation on your link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`] class in order to tell the +Quarkus WebAuthn endpoints to defer those calls to the worker pool. == Testing WebAuthn @@ -1066,8 +1154,10 @@ Testing WebAuthn can be complicated because normally you need a hardware token, testImplementation("io.quarkus:quarkus-test-security-webauthn") ---- -With this, you can use `WebAuthnHardware` to emulate an authenticator token, as well as the -`WebAuthnEndpointHelper` helper methods in order to invoke the WebAuthn endpoints, or even fill your form +With this, you can use link:{webauthn-test-api}/io/quarkus/test/security/webauthn/WebAuthnHardware.html[`WebAuthnHardware`] +to emulate an authenticator token, as well as the +link:{webauthn-test-api}/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.html[`WebAuthnEndpointHelper`] +helper methods in order to invoke the WebAuthn endpoints, or even fill your form data for custom endpoints: [source,java] @@ -1076,25 +1166,24 @@ package org.acme.security.webauthn.test; import static io.restassured.RestAssured.given; +import java.net.URL; import java.util.function.Consumer; -import java.util.function.Supplier; import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; -import io.quarkus.security.webauthn.WebAuthnController; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; import io.restassured.RestAssured; import io.restassured.filter.Filter; -import io.restassured.http.ContentType; import io.restassured.specification.RequestSpecification; import io.vertx.core.json.JsonObject; @QuarkusTest public class WebAuthnResourceTest { - + enum User { USER, ADMIN; } @@ -1102,6 +1191,9 @@ public class WebAuthnResourceTest { DEFAULT, MANUAL; } + @TestHTTPResource + URL url; + @Test public void testWebAuthnUser() { testWebAuthn("FroMage", User.USER, Endpoint.DEFAULT); @@ -1112,51 +1204,50 @@ public class WebAuthnResourceTest { public void testWebAuthnAdmin() { testWebAuthn("admin", User.ADMIN, Endpoint.DEFAULT); } - - private void testWebAuthn(String userName, User user, Endpoint endpoint) { + + private void testWebAuthn(String username, User user, Endpoint endpoint) { Filter cookieFilter = new RenardeCookieFilter(); - WebAuthnHardware token = new WebAuthnHardware(); + WebAuthnHardware token = new WebAuthnHardware(url); verifyLoggedOut(cookieFilter); // two-step registration - String challenge = WebAuthnEndpointHelper.invokeRegistration(userName, cookieFilter); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge(username, cookieFilter); JsonObject registrationJson = token.makeRegistrationJson(challenge); if(endpoint == Endpoint.DEFAULT) - WebAuthnEndpointHelper.invokeCallback(registrationJson, cookieFilter); + WebAuthnEndpointHelper.invokeRegistration(username, registrationJson, cookieFilter); else { invokeCustomEndpoint("/register", cookieFilter, request -> { WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registrationJson); - request.formParam("userName", userName); + request.formParam("username", username); }); } - + // verify that we can access logged-in endpoints - verifyLoggedIn(cookieFilter, userName, user); - + verifyLoggedIn(cookieFilter, username, user); + // logout WebAuthnEndpointHelper.invokeLogout(cookieFilter); - + verifyLoggedOut(cookieFilter); - + // two-step login - challenge = WebAuthnEndpointHelper.invokeLogin(userName, cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge(null, cookieFilter); JsonObject loginJson = token.makeLoginJson(challenge); if(endpoint == Endpoint.DEFAULT) - WebAuthnEndpointHelper.invokeCallback(loginJson, cookieFilter); + WebAuthnEndpointHelper.invokeLogin(loginJson, cookieFilter); else { invokeCustomEndpoint("/login", cookieFilter, request -> { WebAuthnEndpointHelper.addWebAuthnLoginFormParameters(request, loginJson); - request.formParam("userName", userName); }); } // verify that we can access logged-in endpoints - verifyLoggedIn(cookieFilter, userName, user); + verifyLoggedIn(cookieFilter, username, user); // logout WebAuthnEndpointHelper.invokeLogout(cookieFilter); - + verifyLoggedOut(cookieFilter); } @@ -1173,11 +1264,10 @@ public class WebAuthnResourceTest { .statusCode(200) .log().ifValidationFails() .cookie(WebAuthnEndpointHelper.getChallengeCookie(), Matchers.is("")) - .cookie(WebAuthnEndpointHelper.getChallengeUsernameCookie(), Matchers.is("")) .cookie(WebAuthnEndpointHelper.getMainCookie(), Matchers.notNullValue()); } - private void verifyLoggedIn(Filter cookieFilter, String userName, User user) { + private void verifyLoggedIn(Filter cookieFilter, String username, User user) { // public API still good RestAssured.given().filter(cookieFilter) .when() @@ -1191,7 +1281,7 @@ public class WebAuthnResourceTest { .get("/api/public/me") .then() .statusCode(200) - .body(Matchers.is(userName)); + .body(Matchers.is(username)); // user API accessible RestAssured.given().filter(cookieFilter) @@ -1199,8 +1289,8 @@ public class WebAuthnResourceTest { .get("/api/users/me") .then() .statusCode(200) - .body(Matchers.is(userName)); - + .body(Matchers.is(username)); + // admin API? if(user == User.ADMIN) { RestAssured.given().filter(cookieFilter) @@ -1243,7 +1333,7 @@ public class WebAuthnResourceTest { .then() .statusCode(302) .header("Location", Matchers.is("http://localhost:8081/")); - + // admin API not accessible RestAssured.given() .filter(cookieFilter) @@ -1258,32 +1348,45 @@ public class WebAuthnResourceTest { ---- For this test, since we're testing both the provided callback endpoint, which updates users -in its `WebAuthnUserProvider` and the manual `LoginResource` endpoint, which deals with users -manually, we need to override the `WebAuthnUserProvider` with one that doesn't update the +in its link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`] and the manual `LoginResource` endpoint, which deals with users +manually, we need to override the link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`] with one that doesn't update the `scooby` user: [source,java] ---- package org.acme.security.webauthn.test; -import jakarta.enterprise.context.ApplicationScoped; - import org.acme.security.webauthn.MyWebAuthnSetup; +import org.acme.security.webauthn.WebAuthnCredential; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.test.Mock; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; @Mock @ApplicationScoped public class TestUserProvider extends MyWebAuthnSetup { + @Transactional @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { - // delegate the scooby user to the manual endpoint, because if we do it here it will be - // created/updated twice - if(authenticator.getUserName().equals("scooby")) - return Uni.createFrom().nullItem(); - return super.updateOrStoreWebAuthnCredentials(authenticator); + public Uni store(WebAuthnCredentialRecord credentialRecord) { + // this user is handled in the LoginResource endpoint manually + if (credentialRecord.getUsername().equals("scooby")) { + return Uni.createFrom().voidItem(); + } + return super.store(credentialRecord); + } + + @Transactional + @Override + public Uni update(String credentialId, long counter) { + WebAuthnCredential credential = WebAuthnCredential.findByCredentialId(credentialId); + // this user is handled in the LoginResource endpoint manually + if (credential.user.username.equals("scooby")) { + return Uni.createFrom().voidItem(); + } + return super.update(credentialId, counter); } } ---- diff --git a/docs/src/main/asciidoc/smallrye-fault-tolerance.adoc b/docs/src/main/asciidoc/smallrye-fault-tolerance.adoc index 42c47a9d441ee..71a0d095308b8 100644 --- a/docs/src/main/asciidoc/smallrye-fault-tolerance.adoc +++ b/docs/src/main/asciidoc/smallrye-fault-tolerance.adoc @@ -576,7 +576,7 @@ implementation("io.quarkus:quarkus-smallrye-fault-tolerance") == Additional resources SmallRye Fault Tolerance has more features than shown here. -Please check the link:https://smallrye.io/docs/smallrye-fault-tolerance/6.7.0/index.html[SmallRye Fault Tolerance documentation] to learn about them. +Please check the link:https://smallrye.io/docs/smallrye-fault-tolerance/6.7.1/index.html[SmallRye Fault Tolerance documentation] to learn about them. In Quarkus, you can use the SmallRye Fault Tolerance optional features out of the box. @@ -608,7 +608,7 @@ quarkus.fault-tolerance.mp-compatibility=true ---- ==== -The link:https://smallrye.io/docs/smallrye-fault-tolerance/6.7.0/reference/programmatic-api.html[programmatic API] is present and integrated with the declarative, annotation-based API. +The link:https://smallrye.io/docs/smallrye-fault-tolerance/6.7.1/reference/programmatic-api.html[programmatic API] is present and integrated with the declarative, annotation-based API. You can use the `Guard`, `TypedGuard` and `@ApplyGuard` APIs out of the box. Support for Kotlin is present (assuming you use the Quarkus extension for Kotlin), so you can guard your `suspend` functions with fault tolerance annotations. diff --git a/docs/src/main/asciidoc/websockets-next-reference.adoc b/docs/src/main/asciidoc/websockets-next-reference.adoc index 1ff21130b0d13..899010757a947 100644 --- a/docs/src/main/asciidoc/websockets-next-reference.adoc +++ b/docs/src/main/asciidoc/websockets-next-reference.adoc @@ -240,15 +240,19 @@ WebSocket Next supports _blocking_ and _non-blocking_ logic, akin to Quarkus RES Here are the rules governing execution: * Methods annotated with `@RunOnVirtualThread`, `@Blocking` or `@Transactional` are considered blocking. +* Methods declared in a class annotated with `@RunOnVirtualThread` are considered blocking. * Methods annotated with `@NonBlocking` are considered non-blocking. -* Methods declared on a class annotated with `@Transactional` are considered blocking unless annotated with `@NonBlocking`. +* Methods declared in a class annotated with `@Transactional` are considered blocking unless annotated with `@NonBlocking`. * If the method does not declare any of the annotations listed above the execution model is derived from the return type: ** Methods returning `Uni` and `Multi` are considered non-blocking. ** Methods returning `void` or any other type are considered blocking. -* Kotlin `suspend` functions are always considered non-blocking and may not be annotated with `@Blocking`, `@NonBlocking` or `@RunOnVirtualThread`. +* Kotlin `suspend` functions are always considered non-blocking and may not be annotated with `@Blocking`, `@NonBlocking` + or `@RunOnVirtualThread` and may not be in a class annotated with `@RunOnVirtualThread`. * Non-blocking methods must execute on the connection's event loop thread. -* Blocking methods must execute on a worker thread unless annotated with `@RunOnVirtualThread`. -* Methods annotated with `@RunOnVirtualThread` must execute on a virtual thread, each invocation spawns a new virtual thread. +* Blocking methods must execute on a worker thread unless annotated with `@RunOnVirtualThread` or in a class annotated + with `@RunOnVirtualThread`. +* Methods annotated with `@RunOnVirtualThread` or declared in class annotated with `@RunOnVirtualThread` must execute on + a virtual thread, each invocation spawns a new virtual thread. ==== Method parameters diff --git a/docs/src/main/asciidoc/writing-extensions.adoc b/docs/src/main/asciidoc/writing-extensions.adoc index 5e6f84dcf8340..5ef15c1907b8b 100644 --- a/docs/src/main/asciidoc/writing-extensions.adoc +++ b/docs/src/main/asciidoc/writing-extensions.adoc @@ -1584,6 +1584,75 @@ If bytecode recording isn't sufficient, link:https://github.com/quarkusio/gizmo/ Extensions often need a way to determine whether a given class is part of the application's runtime classpath. The proper way for an extension to perform this check is to use `io.quarkus.bootstrap.classloading.QuarkusClassLoader.isClassPresentAtRuntime`. +[[generating-resources]] +=== Generating Resources + +It is possible to generate resources using extensions, in some scenarios you need to generate a resource into `META-INF` directory, the resource can be a service for SPI or a simple HTML, CSS, Javascript files. + +[source,java] +---- +/** + * This build step aggregates all the produced service providers + * and outputs them as resources. + */ +@BuildStep +public void produceServiceFiles( + BuildProducer generatedStaticResourceProducer, + BuildProducer generatedResourceProducer +) throws IOException { + + generatedResourceProducer.produce( // <1> + new GeneratedResourceBuildItem( + "META-INF/services/io.quarkus.services.GreetingService", + """ + public class HelloService implements GreetingService { + + @Override + public void do() { + System.out.println("Hello!"); + } + } + """.getBytes(StandardCharsets.UTF_8))); + + + generatedStaticResourceProducer.produce( // <2> + new GeneratedStaticResourceBuildItem( + "/index.js", + "console.log('Hello World!')".getBytes(StandardCharsets.UTF_8)) + ); +} +---- + +1. Producing a SPI service implementation as a resource in META-INF/services +2. Producing a static resource (e.g., JavaScript file) served by Vertx + +==== Key Points + +. **`GeneratedResourceBuildItem`** +* Generates resources that are persisted in production mode. +* In development and other non-production modes, the resources are kept in memory and loaded using the `QuarkusClassLoader`. + +. **`GeneratedStaticResourceBuildItem`** +* Generates static resources (e.g., files like JavaScript, HTML, or CSS) served by Vertx. +* In development mode, Quarkus serves these resources using a Vertx handler backed by a classloader-based filesystem. + +==== Differences Between `GeneratedResourceBuildItem` and `GeneratedStaticResourceBuildItem` + +While both are used to generate resources, their purposes and behaviors differ: + +**`GeneratedResourceBuildItem`:** + +* Used for resources required at runtime (e.g., SPI service definitions). +* Persisted only in production mode; otherwise, stored in memory. + +**`GeneratedStaticResourceBuildItem`:** + +* Designed for serving static resources via HTTP (e.g., JavaScript or CSS files). +* In development mode, these resources are served dynamically using Vertx. +* Generates a `GeneratedResourceBuildItem`. +* Generates a `AdditionalStaticResourceBuildItem` only on normal mode. + +By using these build items appropriately, you can generate and manage resources effectively within your Quarkus extension. //// TODO: config integration //// diff --git a/docs/src/main/asciidoc/writing-native-applications-tips.adoc b/docs/src/main/asciidoc/writing-native-applications-tips.adoc index b7ca08afcc0c3..3a75185d2411b 100644 --- a/docs/src/main/asciidoc/writing-native-applications-tips.adoc +++ b/docs/src/main/asciidoc/writing-native-applications-tips.adoc @@ -197,7 +197,7 @@ public class MyReflectionConfiguration { } ---- -Note: By default the `@RegisterForReflection` annotation will also registered any potential nested classes for reflection. If you want to avoid this behavior, you can set the `ignoreNested` attribute to `true`. +Note: By default the `@RegisterForReflection` annotation will also register any potential nested classes for reflection. If you want to avoid this behavior, you can set the `ignoreNested` attribute to `true`. ==== Using a configuration file @@ -320,6 +320,7 @@ and in the case of using the Maven configuration instead of `application.propert ---- ==== +[[managing-proxy-classes-app]] === Managing Proxy Classes While writing native application you'll need to define proxy classes at image build time by specifying the list of interfaces that they implement. @@ -331,9 +332,10 @@ In such a situation, the error you might encounter is: com.oracle.svm.core.jdk.UnsupportedFeatureError: Proxy class defined by interfaces [interface org.apache.http.conn.HttpClientConnectionManager, interface org.apache.http.pool.ConnPoolControl, interface com.amazonaws.http.conn.Wrapped] not found. Generating proxy classes at runtime is not supported. Proxy classes need to be defined at image build time by specifying the list of interfaces that they implement. To define proxy classes use -H:DynamicProxyConfigurationFiles= and -H:DynamicProxyConfigurationResources= options. ---- -Solving this issue requires creating a `proxy-config.json` file under the `src/main/resources/META-INF/native-image//` folder. -This way the configuration will be automatically parsed by the native build, without additional configuration. -For more information about the format of this file, see the link:https://www.graalvm.org/{graalvm-docs-version}/reference-manual/native-image/metadata/#dynamic-proxy-metadata-in-json[Dynamic Proxy Metadata in JSON] documentation. +To solve the issue you can create a `proxy-config.json` file under the `src/main/resources/META-INF/native-image//` folder. +For more information about the format of the `proxy-config.json`, see the https://www.graalvm.org/{graalvm-docs-version}/reference-manual/native-image/metadata/#dynamic-proxy-metadata-in-json[Dynamic Proxy Metadata in JSON] documentation. + +Alternatively, you can create a quarkus extension and register the proxy classes as described in <>. [[modularity-benefits]] === Modularity Benefits @@ -633,9 +635,10 @@ Using such a construct means that a `--initialize-at-run-time` option will autom For more information about the `--initialize-at-run-time` option, see the link:https://www.graalvm.org/{graalvm-docs-version}/reference-manual/native-image/optimizations-and-performance/ClassInitialization/[GraalVM Class Initialization in Native Image] guide. ==== +[[managing-proxy-classes-extension]] === Managing Proxy Classes -Very similarly, Quarkus allows extensions authors to register a `NativeImageProxyDefinitionBuildItem`. An example of doing so is: +Similarly, Quarkus allows extensions authors to register a `NativeImageProxyDefinitionBuildItem`. An example of doing so is: [source,java] ---- @@ -650,11 +653,15 @@ public class S3Processor { } ---- -Using such a construct means that a `-H:DynamicProxyConfigurationResources` option will automatically be added to the `native-image` command line. +This will allow Quarkus to generate the necessary configuration for handling the proxy class. + +Alternatively, you may create a `proxy-config.json` as described in <>. [NOTE] ==== -For more information about Proxy Classes, see the link:https://www.graalvm.org/{graalvm-docs-version}/reference-manual/native-image/guides/configure-dynamic-proxies/[GraalVM Configure Dynamic Proxies Manually] guide. +In both cases the configuration will be automatically parsed by the native build, without additional configuration. + +For more information about using Proxy Classes in native executables, see https://www.graalvm.org/jdk21/reference-manual/native-image/dynamic-features/DynamicProxy/[Dynamic Proxy in Native Image] and https://www.graalvm.org/{graalvm-docs-version}/reference-manual/native-image/guides/configure-dynamic-proxies/[GraalVM Configure Dynamic Proxies Manually]. ==== === Logging with Native Image diff --git a/extensions/agroal/deployment/pom.xml b/extensions/agroal/deployment/pom.xml index d98df477aa1a2..d645fa4e3b32e 100644 --- a/extensions/agroal/deployment/pom.xml +++ b/extensions/agroal/deployment/pom.xml @@ -29,6 +29,14 @@ io.quarkus quarkus-agroal + + + io.quarkus + quarkus-agroal-dev + io.quarkus quarkus-agroal-spi @@ -87,6 +95,11 @@ quarkus-smallrye-health-deployment test + + io.quarkus + quarkus-vertx-http-dev-ui-tests + test + diff --git a/extensions/agroal/deployment/src/main/java/io/quarkus/agroal/deployment/devui/AgroalDevUIProcessor.java b/extensions/agroal/deployment/src/main/java/io/quarkus/agroal/deployment/devui/AgroalDevUIProcessor.java new file mode 100644 index 0000000000000..93cc1da30dcbc --- /dev/null +++ b/extensions/agroal/deployment/src/main/java/io/quarkus/agroal/deployment/devui/AgroalDevUIProcessor.java @@ -0,0 +1,41 @@ +package io.quarkus.agroal.deployment.devui; + +import io.quarkus.agroal.runtime.DataSourcesJdbcBuildTimeConfig; +import io.quarkus.agroal.runtime.dev.ui.DatabaseInspector; +import io.quarkus.deployment.IsLocalDevelopment; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.builditem.LaunchModeBuildItem; +import io.quarkus.dev.spi.DevModeType; +import io.quarkus.devui.spi.JsonRPCProvidersBuildItem; +import io.quarkus.devui.spi.page.CardPageBuildItem; +import io.quarkus.devui.spi.page.Page; + +@BuildSteps(onlyIf = IsLocalDevelopment.class) +class AgroalDevUIProcessor { + + @BuildStep + void devUI(DataSourcesJdbcBuildTimeConfig config, + BuildProducer cardPageProducer, + BuildProducer jsonRPCProviderProducer, + LaunchModeBuildItem launchMode) { + + if (launchMode.getDevModeType().isPresent() && launchMode.getDevModeType().get().equals(DevModeType.LOCAL)) { + if (config.devui().enabled()) { + CardPageBuildItem cardPageBuildItem = new CardPageBuildItem(); + + cardPageBuildItem.addPage(Page.webComponentPageBuilder() + .icon("font-awesome-solid:database") + .title("Database view") + .componentLink("qwc-agroal-datasource.js") + .metadata("allowSql", String.valueOf(config.devui().allowSql())) + .metadata("appendSql", config.devui().appendToDefaultSelect().orElse("")) + .metadata("allowedHost", config.devui().allowedDBHost().orElse(null))); + + cardPageProducer.produce(cardPageBuildItem); + jsonRPCProviderProducer.produce(new JsonRPCProvidersBuildItem(DatabaseInspector.class)); + } + } + } +} diff --git a/extensions/agroal/deployment/src/main/resources/dev-ui/qwc-agroal-datasource.js b/extensions/agroal/deployment/src/main/resources/dev-ui/qwc-agroal-datasource.js new file mode 100644 index 0000000000000..bd5e3346859e4 --- /dev/null +++ b/extensions/agroal/deployment/src/main/resources/dev-ui/qwc-agroal-datasource.js @@ -0,0 +1,700 @@ +import { QwcHotReloadElement, html, css} from 'qwc-hot-reload-element'; +import { RouterController } from 'router-controller'; +import { JsonRpc } from 'jsonrpc'; +import '@vaadin/combo-box'; +import '@vaadin/item'; +import '@vaadin/icon'; +import '@vaadin/list-box'; +import '@qomponent/qui-card'; +import '@vaadin/grid'; +import '@vaadin/tabs'; +import '@vaadin/tabsheet'; +import { columnBodyRenderer, columnHeaderRenderer } from '@vaadin/grid/lit.js'; +import '@qomponent/qui-code-block'; +import { notifier } from 'notifier'; +import '@vaadin/progress-bar'; +import '@vaadin/button'; +import '@qomponent/qui-alert'; +import '@vaadin/dialog'; +import { dialogFooterRenderer, dialogHeaderRenderer, dialogRenderer } from '@vaadin/dialog/lit.js'; + +/** + * Allows interaction with your Datasource + */ +export class QwcAgroalDatasource extends QwcHotReloadElement { + jsonRpc = new JsonRpc(this); + configJsonRpc = new JsonRpc("devui-configuration"); + + routerController = new RouterController(this); + + static styles = css` + .dataSources{ + display: flex; + flex-direction: column; + gap: 20px; + height: 100%; + padding-left: 10px; + } + .dataSourcesHeader { + display: flex; + align-items: baseline; + gap: 20px; + border-bottom-style: dotted; + border-bottom-color: var(--lumo-contrast-10pct); + padding-bottom: 10px; + justify-content: space-between; + padding-right: 20px; + } + .dataSourcesHeaderLeft { + display: flex; + align-items: baseline; + gap: 20px; + } + .tablesAndData { + display: flex; + height: 100%; + gap: 20px; + } + + .tableData { + width: 100%; + padding-right: 20px; + } + + .tablesCard { + min-width: 192px; + display: flex; + } + + .fill { + width: 100%; + height: 100%; + } + + .pkicon{ + height: var(--lumo-icon-size-s); + width: var(--lumo-icon-size-s); + } + + .sqlInput { + display: flex; + justify-content: space-between; + gap: 10px; + } + + #sql { + width: 100%; + } + .data { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + height: 100%; + } + + .sqlInputButton{ + height: var(--lumo-icon-size-s); + width: var(--lumo-icon-size-s); + cursor: pointer; + color: var(--lumo-contrast-50pct); + } + + .pager { + display: flex; + justify-content: space-between; + } + + .hidden { + visibility: hidden; + } + + .download { + cursor: pointer; + text-decoration: none; + color: var(--lumo-body-text-color); + } + + .download:hover { + color: var(--lumo-primary-text-color); + text-decoration: underline; + } + + a, a:visited, a:focus, a:active { + text-decoration: none; + color: var(--lumo-body-text-color); + } + a:hover { + text-decoration: none; + color: var(--lumo-primary-text-color); + } + `; + + static properties = { + _dataSources: {state: true}, + _selectedDataSource: {state: true}, + _tables: {state: true}, + _selectedTable: {state: true}, + _selectedTableIndex:{state: true}, + _selectedTableCols:{state: false}, + _currentSQL: {state: true}, + _currentDataSet: {state: true}, + _isWatching: {state: true}, + _watchId: {state: false}, + _currentPageNumber: {state: true}, + _currentNumberOfPages: {state: true}, + _allowSql: {state: true}, + _appendSql: {state: true}, + _allowedHost: {state: true}, + _isAllowedDB: {state: true}, + _displaymessage: {state: true}, + _insertSQL: {state: true}, + _dialogOpened: {state: true} + }; + + constructor() { + super(); + this._dataSources = null; + this._selectedDataSource = null; + this._tables = null; + this._selectedTable = null; + this._selectedTableCols = null; + this._selectedTableIndex = 0; + this._currentSQL = null; + this._currentDataSet = null; + this._isWatching = false; + this._watchId = null; + this._currentPageNumber = 1; + this._currentNumberOfPages = 1; + this._pageSize = 12; + this._isAllowedDB = false; + this._appendSql = ""; + this._allowedHost = null; + this._displaymessage = null; + this._insertSQL = null; + this._dialogOpened = false; + } + + connectedCallback() { + super.connectedCallback(); + + var page = this.routerController.getCurrentPage(); + if(page && page.metadata){ + this._allowSql = (page.metadata.allowSql === "true"); + this._appendSql = page.metadata.appendSql; + this._allowedHost = page.metadata.allowedHost; + }else{ + this._allowSql = false; + this._appendSql = ""; + this._allowedHost = null; + } + + this.jsonRpc.getDataSources().then(jsonRpcResponse => { + this._dataSources = jsonRpcResponse.result.reduce((map, obj) => { + map[obj.name] = obj; + return map; + }, {}); + }); + } + + disconnectedCallback() { + if(this._isWatching)this._unwatch(); + super.disconnectedCallback(); + } + + render() { + if(this._dataSources){ + return html`
+
+
+ ${this._renderDatasourcesComboBox()} + ${this._renderSelectedDatasource()} +
+ ${this._renderExportButton()} +
+ ${this._renderDataOrWarning()} +
+ ${this._renderImportSqlDialog()}`; + } else { + return html`
+
Fetching data sources...
+ +
`; + } + } + + _renderImportSqlDialog(){ + if(this._insertSQL){ + return html` + html` + + + + + + + + + `, + [] + )} + ${dialogRenderer(this._renderImportSqlDialogContents)} + >`; + } + } + + _saveInsertScript(){ + try { + const blob = new Blob([this.value], { type: 'text/sql' }); + const url = URL.createObjectURL(blob); + + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'insert.sql'; + document.body.appendChild(anchor); + anchor.click(); + + URL.revokeObjectURL(url); + anchor.remove(); + + notifier.showInfoMessage("File saved successfully"); + } catch (error) { + notifier.showErrorMessage("Failed to save file: " + error); + } + } + + _copyInsertScript(){ + navigator.clipboard.writeText(this._insertSQL).then( + () => { + notifier.showInfoMessage("Copied to clipboard successfully!"); + }, + (err) => { + notifier.showErrorMessage("Could not copy text: " + err); + } + ); + } + + _closeDialog(){ + this._insertSQL = null; + this._dialogOpened = false; + } + + _renderImportSqlDialogContents(){ + return html``; + } + + _renderDataOrWarning(){ + if(this._isAllowedDB){ + return html`
+
+ ${this._renderTables()} +
+
+ ${this._renderDataAndDefinition()} +
+
`; + }else{ + return html`This feature is not available for remote databases` + } + } + + _renderDatasourcesComboBox(){ + return html` + `; + } + + _renderSelectedDatasource(){ + if(this._selectedDataSource){ + return html`${this._selectedDataSource.jdbcUrl}`; + } + } + + _renderExportButton(){ + if(this._selectedDataSource){ + return html` + + import.sql + `; + } + } + + _renderTables(){ + if(this._tables){ + return html` +
+ + ${this._tables.map((table) => + html`${table.tableName}` + )} + +
+
`; + }else{ + return html`
+
Fetching tables...
+ +
`; + } + + } + + _renderDataAndDefinition(){ + return html` + + + + + ${this._renderWatchButton()} + + + Data + Definition + + +
${this._renderTableData()}
+
${this._renderTableDefinition()}
+
`; + } + + _renderWatchButton(){ + if(this._isWatching){ + return html` + + `; + }else{ + return html` + + `; + } + } + + _renderTableData(){ + if(this._selectedTable && this._currentDataSet && this._currentDataSet.cols){ + return html`
+ + ${this._currentDataSet.cols.map((col) => + this._renderTableHeader(col) + )} + No data. + + ${this._renderPager()} + ${this._renderSqlInput()} +
+ `; + }else if(this._displaymessage){ + return html`${this._displaymessage}`; + }else{ + return html`
+
Fetching data...
+ +
`; + } + } + + _renderTableHeader(col){ + let heading = col; + if(this._selectedTable.primaryKeys.includes(col)){ + heading = col + " *"; + } + return html` this._cellRenderer(col, item), + [] + )}>`; + } + + _renderTableDefinition(){ + if(this._selectedTable){ + return html` + + + + + + `; + } + } + _renderPager() { + return html`
+ ${this._renderPreviousPageButton()} + ${this._currentPageNumber} of ${this._currentNumberOfPages} + ${this._renderNextPageButton()} +
`; + } + + _renderPreviousPageButton(){ + let klas = "pageButton"; + if(this._currentPageNumber === 1){ + klas = "hidden"; + } + return html` + + `; + } + + _renderNextPageButton(){ + let klas = "pageButton"; + if(this._currentPageNumber === this._currentNumberOfPages){ + klas = "hidden"; + } + return html` + + `; + } + + _renderSqlInput(){ + if(this._allowSql){ + return html`
+ + + +
`; + }else { + return html`Allow any SQL execution from here`; + } + } + + _handleAllowSqlChange(){ + this.configJsonRpc.updateProperty({ + 'name': '%dev.quarkus.datasource.dev-ui.allow-sql', + 'value': 'true' + }).then(e => { + this._allowSql = true; + });; + } + + _columnNameRenderer(col){ + if(this._selectedTable.primaryKeys.includes(col.columnName)){ + return html`${col.columnName} `; + }else{ + return html`${col.columnName}`; + } + } + + _cellRenderer(columnName, item){ + const value = item[columnName]; + if(value){ + let colDef = this._selectedTableCols.get(columnName); + let colType = colDef.columnType.toLowerCase(); + + if(colDef.binary){ + const byteCharacters = atob(value); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + + const blob = new Blob([byteArray], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + + return html`
download`; + }else if(colType === "bool" || colType === "boolean"){ // TODO: Can we do int(1) and asume this will be a boolean ? + if(value && value === "true"){ + return html``; + }else { + return html``; + } + } else { + if(value.startsWith("http://") || value.startsWith("https://")){ + return html`${value}`; + }else{ + return html`${value}`; + } + } + } + } + + _watch(){ + this._isWatching = true; + this._watchId = setInterval(() => { + this.hotReload(); + }, 3000); + } + + _unwatch(){ + this._isWatching = false; + clearInterval(this._watchId); + this._watchId = null; + } + + _onDataSourceChanged(event) { + const selectedValue = event.detail.value; + if(selectedValue in this._dataSources){ + this._selectedDataSource = this._dataSources[selectedValue]; + this._isAllowedDB = this._isAllowedHostDatabase(); + if(this._isAllowedDB){ + this._fetchTableDefinitions(); + } + } + } + + _onTableChanged(event){ + this._selectedTableIndex = event.detail.value; + this._selectedTable = this._tables[this._selectedTableIndex]; + this._clearSqlInput(); + } + + _previousPage(){ + if(this._currentPageNumber!=1){ + this._currentPageNumber = this._currentPageNumber - 1; + this._executeCurrentSQL(); + } + } + + _nextPage(){ + this._currentPageNumber = this._currentPageNumber + 1; + this._executeCurrentSQL(); + } + + _getNumberOfPages(){ + if(this._currentDataSet){ + if(this._currentDataSet.totalNumberOfElements > this._pageSize){ + return Math.ceil(this._currentDataSet.totalNumberOfElements/this._pageSize); + }else { + return 1; + } + } + } + + _executeCurrentSQL(){ + if(this._currentSQL){ + this.jsonRpc.executeSQL({ + datasource:this._selectedDataSource.name, + sql:this._currentSQL, + pageNumber: this._currentPageNumber, + pageSize: this._pageSize + }).then(jsonRpcResponse => { + if(jsonRpcResponse.result.error){ + notifier.showErrorMessage(jsonRpcResponse.result.error); + } else if (jsonRpcResponse.result.message){ + notifier.showInfoMessage(jsonRpcResponse.result.message); + this._clearSqlInput(); + } else { + this._currentDataSet = jsonRpcResponse.result; + this._currentNumberOfPages = this._getNumberOfPages(); + } + }); + } + } + + _fetchTableDefinitions() { + if(this._selectedDataSource){ + this._insertSQL = null; + this.jsonRpc.getTables({datasource:this._selectedDataSource.name}).then(jsonRpcResponse => { + this._tables = jsonRpcResponse.result; + this._selectedTable = this._tables[this._selectedTableIndex]; + if(this._selectedTable){ + this._displaymessage = null; + this._selectedTableCols = this._selectedTable.columns.reduce((acc, obj) => { + acc.set(obj.columnName, obj); + return acc; + }, new Map()); + this._executeCurrentSQL(); + }else { + this._displaymessage = "No tables found"; + } + }); + } + } + + _createImportSql(){ + if(this._selectedDataSource){ + this.jsonRpc.getInsertScript({datasource:this._selectedDataSource.name}).then(jsonRpcResponse => { + this._insertSQL = jsonRpcResponse.result; + this._dialogOpened = true; + }); + } + } + + _executeClicked(){ + let newValue = this.shadowRoot.getElementById('sql').getAttribute('value'); + this._executeSQL(newValue); + } + + _clearSqlInput(){ + if(this._selectedTable){ + if(this._appendSql){ + this._executeSQL("select * from " + this._selectedTable.tableName + " " + this._appendSql); + }else{ + this._executeSQL("select * from " + this._selectedTable.tableName); + } + } + } + + _shiftEnterPressed(event){ + this._executeSQL(event.detail.content); + } + + _executeSQL(sql){ + this._currentSQL = sql.trim(); + this._executeCurrentSQL(); + } + + _startsWithIgnoreCaseAndSpaces(str, searchString) { + return str.trim().toLowerCase().startsWith(searchString.toLowerCase()); + } + + hotReload(){ + this._fetchTableDefinitions(); + } + + _isAllowedHostDatabase() { + + let jdbcUrl = this._selectedDataSource.jdbcUrl; + try { + if (jdbcUrl.startsWith("jdbc:h2:mem:") || jdbcUrl.startsWith("jdbc:h2:file:")) { + return true; + } + + if (jdbcUrl.startsWith("jdbc:derby:memory:")) { + return true; + } + + if (jdbcUrl.startsWith("jdbc:derby:")) { + const derbyUri = jdbcUrl.replace("jdbc:", ""); + if (derbyUri.startsWith("localhost") || derbyUri.startsWith("127.0.0.1")) { + return true; + } + if(this._allowedHost && this._allowedHost!="" && derbyUri.startsWith(this._allowedHost)){ + return true; + } + } + + const urlPattern = /^jdbc:[^:]+:\/\/([^:/]+)(:\d+)?/; + const match = jdbcUrl.match(urlPattern); + + if (match) { + const host = match[1]; + if(host === "localhost" || host === "127.0.0.1" || host === "::1"){ + return true; + } + if(this._allowedHost && this._allowedHost!="" && host === this._allowedHost){ + return true; + } + } + + return false; + } catch (e) { + console.error(e); + return false; + } + } +} +customElements.define('qwc-agroal-datasource', QwcAgroalDatasource); diff --git a/extensions/agroal/deployment/src/test/java/io/quarkus/agroal/test/AgroalDevUITestCase.java b/extensions/agroal/deployment/src/test/java/io/quarkus/agroal/test/AgroalDevUITestCase.java new file mode 100644 index 0000000000000..b4c0611cf5249 --- /dev/null +++ b/extensions/agroal/deployment/src/test/java/io/quarkus/agroal/test/AgroalDevUITestCase.java @@ -0,0 +1,64 @@ +package io.quarkus.agroal.test; + +import java.util.Map; +import java.util.function.Supplier; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.fasterxml.jackson.databind.JsonNode; + +import io.quarkus.devui.tests.DevUIJsonRPCTest; +import io.quarkus.test.QuarkusDevModeTest; + +public class AgroalDevUITestCase extends DevUIJsonRPCTest { + + @RegisterExtension + public static final QuarkusDevModeTest test = new QuarkusDevModeTest() + .setArchiveProducer(new Supplier<>() { + @Override + public JavaArchive get() { + return ShrinkWrap.create(JavaArchive.class) + .addClass(DevModeResource.class) + .add(new StringAsset("quarkus.datasource.db-kind=h2\n" + + "quarkus.datasource.username=USERNAME-NAMED\n" + + "quarkus.datasource.jdbc.url=jdbc:h2:tcp://localhost/mem:testing\n" + + "quarkus.datasource.jdbc.driver=org.h2.Driver\n"), "application.properties"); + } + }); + + public AgroalDevUITestCase() { + super("io.quarkus.quarkus-agroal"); + } + + @Test + public void testGetDataSources() throws Exception { + JsonNode datasources = super.executeJsonRPCMethod("getDataSources"); + + Assertions.assertNotNull(datasources); + Assertions.assertTrue(datasources.isArray()); + Assertions.assertEquals(1, datasources.size()); + + JsonNode datasource = datasources.get(0); + Assertions.assertNotNull(datasource); + + JsonNode nameNode = datasource.get("name"); + Assertions.assertNotNull(nameNode); + String name = nameNode.asText(); + Assertions.assertNotNull(name); + Assertions.assertEquals(DEFAULT_DATASOURCE, name); + } + + @Test + public void testGetTables() throws Exception { + JsonNode tables = super.executeJsonRPCMethod("getTables", Map.of("datasource", DEFAULT_DATASOURCE)); + Assertions.assertNotNull(tables); + Assertions.assertTrue(tables.isArray()); + } + + private static final String DEFAULT_DATASOURCE = ""; +} diff --git a/extensions/agroal/pom.xml b/extensions/agroal/pom.xml index 2ebcce6c78b23..4ae2ad530ee32 100644 --- a/extensions/agroal/pom.xml +++ b/extensions/agroal/pom.xml @@ -17,5 +17,6 @@ spi deployment runtime + runtime-dev diff --git a/extensions/agroal/runtime-dev/pom.xml b/extensions/agroal/runtime-dev/pom.xml new file mode 100644 index 0000000000000..a5f8c9be98dc6 --- /dev/null +++ b/extensions/agroal/runtime-dev/pom.xml @@ -0,0 +1,22 @@ + + + + quarkus-agroal-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-agroal-dev + Quarkus - Agroal - Runtime Dev mode + JDBC Datasources and connection pooling - Dev mode only + + + + ${project.groupId} + quarkus-agroal + + + \ No newline at end of file diff --git a/extensions/agroal/runtime-dev/src/main/java/io/quarkus/agroal/runtime/dev/ui/DatabaseInspector.java b/extensions/agroal/runtime-dev/src/main/java/io/quarkus/agroal/runtime/dev/ui/DatabaseInspector.java new file mode 100644 index 0000000000000..2b58c7fff364d --- /dev/null +++ b/extensions/agroal/runtime-dev/src/main/java/io/quarkus/agroal/runtime/dev/ui/DatabaseInspector.java @@ -0,0 +1,376 @@ +package io.quarkus.agroal.runtime.dev.ui; + +import java.io.IOException; +import java.io.StringWriter; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.Date; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.sql.DataSource; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; + +import io.agroal.api.AgroalDataSource; +import io.agroal.api.configuration.AgroalDataSourceConfiguration; +import io.quarkus.agroal.runtime.AgroalDataSourceSupport; +import io.quarkus.agroal.runtime.DataSources; +import io.quarkus.arc.Arc; +import io.quarkus.datasource.common.runtime.DataSourceUtil; +import io.quarkus.logging.Log; +import io.quarkus.runtime.LaunchMode; + +public final class DatabaseInspector { + + @Inject + Instance dataSources; + + private final Map checkedDataSources = new HashMap<>(); + + private boolean isDev = false; + private boolean allowSql = false; + private String allowedHost = null; + + public DatabaseInspector() { + LaunchMode currentMode = LaunchMode.current(); + this.isDev = currentMode == LaunchMode.DEVELOPMENT && !LaunchMode.isRemoteDev(); + + Config config = ConfigProvider.getConfig(); + this.allowSql = config.getOptionalValue("quarkus.datasource.dev-ui.allow-sql", Boolean.class) + .orElse(false); + + this.allowedHost = config.getOptionalValue("quarkus.datasource.dev-ui.allowed-db-host", String.class) + .orElse(null); + + } + + @PostConstruct + protected void init() { + if (!dataSources.isResolvable()) { + // No configured Agroal datasource at build time. + return; + } + + if (isDev) { + AgroalDataSourceSupport agroalSupport = Arc.container().instance(AgroalDataSourceSupport.class) + .get(); + for (String name : agroalSupport.entries.keySet()) { + DataSource ds = dataSources.get().getDataSource(name); + if (ds != null) { + checkedDataSources.put(name, ds); + } + } + } + } + + public List getDataSources() { + if (isDev) { + List datasources = new ArrayList<>(); + + for (String ds : checkedDataSources.keySet()) { + datasources.add(getDatasource(ds)); + } + + return datasources; + } + return List.of(); + } + + private Datasource getDatasource(String datasource) { + if (isDev) { + AgroalDataSource ads = (AgroalDataSource) checkedDataSources.get(datasource); + if (isAllowedDatabase(ads)) { + AgroalDataSourceConfiguration configuration = ads.getConfiguration(); + + String jdbcUrl = configuration.connectionPoolConfiguration().connectionFactoryConfiguration().jdbcUrl(); + boolean isDefault = DataSourceUtil.isDefault(datasource); + + return new Datasource(datasource, jdbcUrl, isDefault); + } + } + return null; + } + + public List getTables(String datasource) { + if (isDev) { + List
tableList = new ArrayList<>(); + try { + AgroalDataSource ads = (AgroalDataSource) checkedDataSources.get(datasource); + if (isAllowedDatabase(ads)) { + try (Connection connection = ads.getConnection()) { + DatabaseMetaData metaData = connection.getMetaData(); + + // Get all tables + try (ResultSet tables = metaData.getTables(null, null, "%", new String[] { "TABLE" })) { + while (tables.next()) { + String tableName = tables.getString("TABLE_NAME"); + String tableSchema = tables.getString("TABLE_SCHEM"); + + // Get the Primary Keys + List primaryKeyList = getPrimaryKeys(metaData, tableSchema, tableName); + + // Get columns for each table + List columnList = new ArrayList<>(); + try (ResultSet columns = metaData.getColumns(null, tableSchema, tableName, "%")) { + while (columns.next()) { + String columnName = columns.getString("COLUMN_NAME"); + String columnType = columns.getString("TYPE_NAME"); + int columnSize = columns.getInt("COLUMN_SIZE"); + String nullable = columns.getString("IS_NULLABLE"); + int dataType = columns.getInt("DATA_TYPE"); + columnList + .add(new Column(columnName, columnType, columnSize, nullable, + isBinary(dataType))); + + } + } + tableList.add(new Table(tableSchema, tableName, primaryKeyList, columnList)); + } + } + } + } + } catch (SQLException ex) { + throw new RuntimeException(ex); + } + + return tableList; + } + return null; + } + + public DataSet executeSQL(String datasource, String sql, Integer pageNumber, Integer pageSize) { + if (isDev && sqlIsValid(sql)) { + try { + AgroalDataSource ads = (AgroalDataSource) checkedDataSources.get(datasource); + if (isAllowedDatabase(ads)) { + try (Connection connection = ads.getConnection()) { + // Create a scrollable ResultSet + try (Statement statement = connection.createStatement(ResultSet.TYPE_SCROLL_INSENSITIVE, + ResultSet.CONCUR_READ_ONLY)) { + boolean hasResultSet = statement.execute(sql); + if (hasResultSet) { + try (ResultSet resultSet = statement.executeQuery(sql)) { + + // Get the total number of rows + resultSet.last(); + int totalNumberOfElements = resultSet.getRow(); + + // Get the column metadata + List cols = new ArrayList<>(); + ResultSetMetaData metaData = resultSet.getMetaData(); + int columnCount = metaData.getColumnCount(); + for (int i = 1; i <= columnCount; i++) { + String columnName = metaData.getColumnName(i); + cols.add(columnName); + } + + int startRow = (pageNumber - 1) * pageSize + 1; + + List> rows = new ArrayList<>(); + + if (resultSet.absolute(startRow)) { + int rowCount = 0; + + do { + Map row = new HashMap<>(); + for (int i = 1; i <= columnCount; i++) { + String columnName = metaData.getColumnName(i); + boolean isBinary = isBinary(metaData.getColumnType(i)); + if (!isBinary) { + Object columnValue = resultSet.getObject(i); + row.put(columnName, String.valueOf(columnValue)); + } else { + byte[] binary = resultSet.getBytes(i); + if (!resultSet.wasNull()) { + row.put(columnName, Base64.getEncoder().encodeToString(binary)); + } else { + row.put(columnName, null); + } + } + } + rows.add(row); + rowCount++; + } while (resultSet.next() && rowCount < pageSize); + } + return new DataSet(cols, rows, null, null, totalNumberOfElements); + } + } else { + // Query did not return a ResultSet (e.g., DELETE, UPDATE) + int updateCount = statement.getUpdateCount(); + String message = "Query executed successfully. Rows affected: " + updateCount; + return new DataSet(null, null, null, message, -1); + } + } catch (Exception e) { + return new DataSet(null, null, e.getMessage(), null, -1); + } + } + } else { + return new DataSet(null, null, "Only supported for Local Databases", null, -1); + } + } catch (SQLException ex) { + return new DataSet(null, null, ex.getMessage(), null, -1); + } + } else { + return new DataSet(null, null, "Unknown Error", null, -1); + } + } + + public String getInsertScript(String datasource) { + if (isDev) { + try { + AgroalDataSource ads = (AgroalDataSource) checkedDataSources.get(datasource); + if (isAllowedDatabase(ads)) { + try (Connection connection = ads.getConnection(); + StringWriter writer = new StringWriter()) { + DatabaseMetaData metaData = connection.getMetaData(); + try (ResultSet tables = metaData.getTables(null, null, "%", new String[] { "TABLE" })) { + while (tables.next()) { + String tableName = tables.getString("TABLE_NAME"); + exportTable(connection, writer, tableName); + } + } + + return writer.toString(); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + } catch (SQLException ex) { + throw new RuntimeException(ex); + } + } + return null; + } + + private void exportTable(Connection conn, StringWriter writer, String tableName) throws SQLException, IOException { + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT * FROM " + tableName)) { + + ResultSetMetaData metaData = rs.getMetaData(); + int columnCount = metaData.getColumnCount(); + + while (rs.next()) { + StringBuilder insertQuery = new StringBuilder("INSERT INTO " + tableName + " ("); + + for (int i = 1; i <= columnCount; i++) { + insertQuery.append(metaData.getColumnName(i)); + if (i < columnCount) + insertQuery.append(", "); + } + + insertQuery.append(") VALUES ("); + + for (int i = 1; i <= columnCount; i++) { + Object value = rs.getObject(i); + if (value == null) { + insertQuery.append("NULL"); + } else if (value instanceof String || value instanceof Date || value instanceof Timestamp) { + insertQuery.append("'").append(value.toString().replace("'", "''")).append("'"); + } else { + insertQuery.append(value.toString()); + } + if (i < columnCount) + insertQuery.append(", "); + } + + insertQuery.append(");\n"); + writer.write(insertQuery.toString()); + } + } + } + + private boolean sqlIsValid(String sql) { + if (sql == null || sql.isEmpty()) + return false; + if (allowSql) { + return true; + } else { + String lsql = sql.toLowerCase().trim(); + return lsql.startsWith("select") + && !lsql.contains("update ") + && !lsql.contains("delete ") + && !lsql.contains("insert ") + && !lsql.contains("create ") + && !lsql.contains("drop "); // Having a sql with those nested is invalid anyway + } + } + + private List getPrimaryKeys(DatabaseMetaData metaData, String tableSchema, String tableName) throws SQLException { + List primaryKeyList = new ArrayList<>(); + try (ResultSet primaryKeys = metaData.getPrimaryKeys(null, tableSchema, tableName)) { + while (primaryKeys.next()) { + String primaryKeyColumn = primaryKeys.getString("COLUMN_NAME"); + primaryKeyList.add(primaryKeyColumn); + } + } + return primaryKeyList; + } + + private boolean isAllowedDatabase(AgroalDataSource ads) { + AgroalDataSourceConfiguration configuration = ads.getConfiguration(); + String jdbcUrl = configuration.connectionPoolConfiguration().connectionFactoryConfiguration().jdbcUrl(); + + try { + if (jdbcUrl.startsWith("jdbc:h2:mem:") || jdbcUrl.startsWith("jdbc:h2:file:") + || jdbcUrl.startsWith("jdbc:h2:tcp://localhost") + || (this.allowedHost != null && !this.allowedHost.isBlank() + && jdbcUrl.startsWith("jdbc:h2:tcp://" + this.allowedHost)) + || jdbcUrl.startsWith("jdbc:derby:memory:")) { + return true; + } + + String cleanUrl = jdbcUrl.replace("jdbc:", ""); + URI uri = new URI(cleanUrl); + + String host = uri.getHost(); + + return host != null && ((host.equals("localhost") || host.equals("127.0.0.1") || host.equals("::1")) || + (this.allowedHost != null && !this.allowedHost.isBlank() && host.equalsIgnoreCase(this.allowedHost))); + + } catch (URISyntaxException e) { + Log.warn(e.getMessage()); + } + + return false; + } + + private boolean isBinary(int dataType) { + return dataType == Types.BLOB || + dataType == Types.VARBINARY || + dataType == Types.LONGVARBINARY || + dataType == Types.BINARY || + dataType == Types.JAVA_OBJECT || + dataType == Types.OTHER; + } + + private static record Column(String columnName, String columnType, int columnSize, String nullable, boolean binary) { + } + + private static record Table(String tableSchema, String tableName, List primaryKeys, List columns) { + } + + private static record Datasource(String name, String jdbcUrl, boolean isDefault) { + } + + private static record DataSet(List cols, List> data, String error, String message, + int totalNumberOfElements) { + } +} diff --git a/extensions/agroal/runtime/pom.xml b/extensions/agroal/runtime/pom.xml index 1706cf5f5d53c..72a2be3bbbfc6 100644 --- a/extensions/agroal/runtime/pom.xml +++ b/extensions/agroal/runtime/pom.xml @@ -90,6 +90,19 @@ io.quarkus.agroal + + + process-resources + + extension-descriptor + + + + ${project.groupId}:${project.artifactId}-dev:${project.version} + + + + maven-compiler-plugin diff --git a/extensions/agroal/runtime/src/main/java/io/quarkus/agroal/runtime/DataSourcesJdbcBuildTimeConfig.java b/extensions/agroal/runtime/src/main/java/io/quarkus/agroal/runtime/DataSourcesJdbcBuildTimeConfig.java index b8a76bf2158d4..c239b2be94d7f 100644 --- a/extensions/agroal/runtime/src/main/java/io/quarkus/agroal/runtime/DataSourcesJdbcBuildTimeConfig.java +++ b/extensions/agroal/runtime/src/main/java/io/quarkus/agroal/runtime/DataSourcesJdbcBuildTimeConfig.java @@ -1,6 +1,7 @@ package io.quarkus.agroal.runtime; import java.util.Map; +import java.util.Optional; import io.quarkus.datasource.common.runtime.DataSourceUtil; import io.quarkus.runtime.annotations.ConfigDocMapKey; @@ -8,7 +9,9 @@ import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; import io.smallrye.config.WithDefaults; +import io.smallrye.config.WithName; import io.smallrye.config.WithParentName; import io.smallrye.config.WithUnnamedKey; @@ -25,6 +28,13 @@ public interface DataSourcesJdbcBuildTimeConfig { @WithUnnamedKey(DataSourceUtil.DEFAULT_DATASOURCE_NAME) Map dataSources(); + /** + * Dev UI. + */ + @WithDefaults + @WithName("dev-ui") + DevUIBuildTimeConfig devui(); + @ConfigGroup public interface DataSourceJdbcOuterNamedBuildTimeConfig { @@ -33,4 +43,30 @@ public interface DataSourceJdbcOuterNamedBuildTimeConfig { */ DataSourceJdbcBuildTimeConfig jdbc(); } + + @ConfigGroup + public interface DevUIBuildTimeConfig { + + /** + * Activate or disable the dev ui page. + */ + @WithDefault("true") + public boolean enabled(); + + /** + * Allow sql queries in the Dev UI page + */ + @WithDefault("false") + public boolean allowSql(); + + /** + * Append this to the select done to fetch the table values. eg: LIMIT 100 or TOP 100 + */ + public Optional appendToDefaultSelect(); + + /** + * Allowed database host. By default only localhost is allowed. Any provided host here will also be allowed + */ + public Optional allowedDBHost(); + } } diff --git a/extensions/amazon-lambda-http/deployment/pom.xml b/extensions/amazon-lambda-http/deployment/pom.xml index d7088a8788cc3..9dfa37b46b7a1 100644 --- a/extensions/amazon-lambda-http/deployment/pom.xml +++ b/extensions/amazon-lambda-http/deployment/pom.xml @@ -68,9 +68,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/extensions/amazon-lambda-http/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/AmazonLambdaHttpProcessor.java b/extensions/amazon-lambda-http/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/AmazonLambdaHttpProcessor.java index dbe75f0b8d76f..e64a2b3e3b5a8 100644 --- a/extensions/amazon-lambda-http/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/AmazonLambdaHttpProcessor.java +++ b/extensions/amazon-lambda-http/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/AmazonLambdaHttpProcessor.java @@ -49,7 +49,7 @@ public void setupCDI(BuildProducer additionalBeans) { @BuildStep public void setupSecurity(BuildProducer additionalBeans, LambdaHttpBuildTimeConfig config) { - if (!config.enableSecurity) + if (!config.enableSecurity()) return; AdditionalBeanBuildItem.Builder builder = AdditionalBeanBuildItem.builder().setUnremovable(); diff --git a/extensions/amazon-lambda-http/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/LambdaHttpBuildTimeConfig.java b/extensions/amazon-lambda-http/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/LambdaHttpBuildTimeConfig.java index ba81e36664ba0..6d4d9c546bc78 100644 --- a/extensions/amazon-lambda-http/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/LambdaHttpBuildTimeConfig.java +++ b/extensions/amazon-lambda-http/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/LambdaHttpBuildTimeConfig.java @@ -1,14 +1,16 @@ package io.quarkus.amazon.lambda.http.deployment; -import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; @ConfigRoot -public class LambdaHttpBuildTimeConfig { +@ConfigMapping(prefix = "quarkus.lambda-http") +public interface LambdaHttpBuildTimeConfig { /** * Enable security mechanisms to process lambda and AWS based security (i.e. Cognito, IAM) from * the http event sent from API Gateway */ - @ConfigItem(defaultValue = "false") - public boolean enableSecurity; + @WithDefault("false") + boolean enableSecurity(); } diff --git a/extensions/amazon-lambda-http/maven-archetype/src/main/resources/archetype-resources/pom.xml b/extensions/amazon-lambda-http/maven-archetype/src/main/resources/archetype-resources/pom.xml index 4a7f0c66550b1..fc4a0adb4899e 100644 --- a/extensions/amazon-lambda-http/maven-archetype/src/main/resources/archetype-resources/pom.xml +++ b/extensions/amazon-lambda-http/maven-archetype/src/main/resources/archetype-resources/pom.xml @@ -18,7 +18,7 @@ quarkus-bom io.quarkus 999-SNAPSHOT - 3.5.0 + 3.5.2 diff --git a/extensions/amazon-lambda-http/pom.xml b/extensions/amazon-lambda-http/pom.xml index 78c20a2fb26b2..4d8f0ce264b8f 100644 --- a/extensions/amazon-lambda-http/pom.xml +++ b/extensions/amazon-lambda-http/pom.xml @@ -19,7 +19,6 @@ runtime http-event-server deployment - maven-archetype diff --git a/extensions/amazon-lambda-http/runtime/pom.xml b/extensions/amazon-lambda-http/runtime/pom.xml index f71b749e0497d..6a54eb16c6040 100644 --- a/extensions/amazon-lambda-http/runtime/pom.xml +++ b/extensions/amazon-lambda-http/runtime/pom.xml @@ -76,9 +76,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/CognitoPrincipal.java b/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/CognitoPrincipal.java index a6a8714b7bf06..419388e56733c 100644 --- a/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/CognitoPrincipal.java +++ b/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/CognitoPrincipal.java @@ -76,8 +76,8 @@ public long getIssuedAtTime() { @Override public Set getGroups() { if (groups == null) { - if (jwt.getClaims().containsKey(LambdaHttpRecorder.config.cognitoRoleClaim)) { - String claim = jwt.getClaims().get(LambdaHttpRecorder.config.cognitoRoleClaim); + if (jwt.getClaims().containsKey(LambdaHttpRecorder.config.cognitoRoleClaim())) { + String claim = jwt.getClaims().get(LambdaHttpRecorder.config.cognitoRoleClaim()); Matcher matcher = LambdaHttpRecorder.groupPattern.matcher(claim); groups = new HashSet<>(); while (matcher.find()) { diff --git a/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/DefaultLambdaIdentityProvider.java b/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/DefaultLambdaIdentityProvider.java index 4984b060fc758..41dce4966df13 100644 --- a/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/DefaultLambdaIdentityProvider.java +++ b/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/DefaultLambdaIdentityProvider.java @@ -27,7 +27,7 @@ public Class getRequestType() { public Uni authenticate(DefaultLambdaAuthenticationRequest request, AuthenticationRequestContext context) { APIGatewayV2HTTPEvent event = request.getEvent(); - SecurityIdentity identity = authenticate(event, LambdaHttpRecorder.config.mapCognitoToRoles); + SecurityIdentity identity = authenticate(event, LambdaHttpRecorder.config.mapCognitoToRoles()); if (identity == null) { return Uni.createFrom().optional(Optional.empty()); } diff --git a/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpConfig.java b/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpConfig.java index a89d1ec1159fa..acb5db14f201c 100644 --- a/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpConfig.java +++ b/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpConfig.java @@ -1,34 +1,36 @@ package io.quarkus.amazon.lambda.http; -import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; @ConfigRoot(phase = ConfigPhase.RUN_TIME) -public class LambdaHttpConfig { +@ConfigMapping(prefix = "quarkus.lambda-http") +public interface LambdaHttpConfig { /** * If true, Quarkus will map claims from Cognito to Quarkus security roles. * The "cognito:groups" claim will be used by default. Change cognitoRoleClaim * config value to change the claim source. - * + *

* True by default */ - @ConfigItem(defaultValue = "true") - public boolean mapCognitoToRoles; + @WithDefault("true") + boolean mapCognitoToRoles(); /** * Cognito claim that contains roles you want to map. Defaults to "cognito:groups" */ - @ConfigItem(defaultValue = "cognito:groups") - public String cognitoRoleClaim; + @WithDefault("cognito:groups") + String cognitoRoleClaim(); /** * Regular expression to locate role values within a Cognito claim string. - * By default it looks for space delimited strings enclosed in brackets + * By default, it looks for space delimited strings enclosed in brackets * "[^\[\] \t]+" */ - @ConfigItem(defaultValue = "[^\\[\\] \\t]+") - public String cognitoClaimMatcher; + @WithDefault(value = "[^\\[\\] \\t]+") + String cognitoClaimMatcher(); } diff --git a/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpRecorder.java b/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpRecorder.java index c5c6a40677b35..616ca65c9c421 100644 --- a/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpRecorder.java +++ b/extensions/amazon-lambda-http/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpRecorder.java @@ -11,7 +11,7 @@ public class LambdaHttpRecorder { public void setConfig(LambdaHttpConfig c) { config = c; - String pattern = c.cognitoClaimMatcher; + String pattern = c.cognitoClaimMatcher(); groupPattern = Pattern.compile(pattern); } } diff --git a/extensions/amazon-lambda-rest/deployment/pom.xml b/extensions/amazon-lambda-rest/deployment/pom.xml index a4680f09844c4..8a9646c238b2d 100644 --- a/extensions/amazon-lambda-rest/deployment/pom.xml +++ b/extensions/amazon-lambda-rest/deployment/pom.xml @@ -68,9 +68,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/extensions/amazon-lambda-rest/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/AmazonLambdaHttpProcessor.java b/extensions/amazon-lambda-rest/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/AmazonLambdaHttpProcessor.java index d9c77a471769b..8925222267404 100644 --- a/extensions/amazon-lambda-rest/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/AmazonLambdaHttpProcessor.java +++ b/extensions/amazon-lambda-rest/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/AmazonLambdaHttpProcessor.java @@ -49,7 +49,7 @@ public void setupCDI(BuildProducer additionalBeans) { @BuildStep public void setupSecurity(BuildProducer additionalBeans, LambdaHttpBuildTimeConfig config) { - if (!config.enableSecurity) + if (!config.enableSecurity()) return; AdditionalBeanBuildItem.Builder builder = AdditionalBeanBuildItem.builder().setUnremovable(); diff --git a/extensions/amazon-lambda-rest/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/LambdaHttpBuildTimeConfig.java b/extensions/amazon-lambda-rest/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/LambdaHttpBuildTimeConfig.java index ba81e36664ba0..6d4d9c546bc78 100644 --- a/extensions/amazon-lambda-rest/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/LambdaHttpBuildTimeConfig.java +++ b/extensions/amazon-lambda-rest/deployment/src/main/java/io/quarkus/amazon/lambda/http/deployment/LambdaHttpBuildTimeConfig.java @@ -1,14 +1,16 @@ package io.quarkus.amazon.lambda.http.deployment; -import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; @ConfigRoot -public class LambdaHttpBuildTimeConfig { +@ConfigMapping(prefix = "quarkus.lambda-http") +public interface LambdaHttpBuildTimeConfig { /** * Enable security mechanisms to process lambda and AWS based security (i.e. Cognito, IAM) from * the http event sent from API Gateway */ - @ConfigItem(defaultValue = "false") - public boolean enableSecurity; + @WithDefault("false") + boolean enableSecurity(); } diff --git a/extensions/amazon-lambda-rest/maven-archetype/src/main/resources/archetype-resources/pom.xml b/extensions/amazon-lambda-rest/maven-archetype/src/main/resources/archetype-resources/pom.xml index b2102594bf46d..4d72ebc7bf0b7 100644 --- a/extensions/amazon-lambda-rest/maven-archetype/src/main/resources/archetype-resources/pom.xml +++ b/extensions/amazon-lambda-rest/maven-archetype/src/main/resources/archetype-resources/pom.xml @@ -18,7 +18,7 @@ quarkus-bom io.quarkus 999-SNAPSHOT - 3.5.0 + 3.5.2 diff --git a/extensions/amazon-lambda-rest/runtime/pom.xml b/extensions/amazon-lambda-rest/runtime/pom.xml index 9dd7859810e4c..b5c1e2111b4f0 100644 --- a/extensions/amazon-lambda-rest/runtime/pom.xml +++ b/extensions/amazon-lambda-rest/runtime/pom.xml @@ -57,9 +57,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/CognitoPrincipal.java b/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/CognitoPrincipal.java index 3f49b91c199de..54c9c13530df3 100644 --- a/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/CognitoPrincipal.java +++ b/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/CognitoPrincipal.java @@ -81,7 +81,7 @@ public long getIssuedAtTime() { @Override public Set getGroups() { if (groups == null) { - String grpClaim = claims.getClaim(LambdaHttpRecorder.config.cognitoRoleClaim); + String grpClaim = claims.getClaim(LambdaHttpRecorder.config.cognitoRoleClaim()); if (grpClaim != null) { Matcher matcher = LambdaHttpRecorder.groupPattern.matcher(grpClaim); groups = new HashSet<>(); diff --git a/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/DefaultLambdaIdentityProvider.java b/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/DefaultLambdaIdentityProvider.java index f9724fa8b4837..002d2af6dd60a 100644 --- a/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/DefaultLambdaIdentityProvider.java +++ b/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/DefaultLambdaIdentityProvider.java @@ -27,7 +27,7 @@ public Class getRequestType() { public Uni authenticate(DefaultLambdaAuthenticationRequest request, AuthenticationRequestContext context) { AwsProxyRequest event = request.getEvent(); - SecurityIdentity identity = authenticate(event, LambdaHttpRecorder.config.mapCognitoToRoles); + SecurityIdentity identity = authenticate(event, LambdaHttpRecorder.config.mapCognitoToRoles()); if (identity == null) { return Uni.createFrom().optional(Optional.empty()); } diff --git a/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpConfig.java b/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpConfig.java index 08155dc49f59c..55ebc8c911fac 100644 --- a/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpConfig.java +++ b/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpConfig.java @@ -1,11 +1,13 @@ package io.quarkus.amazon.lambda.http; -import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; @ConfigRoot(phase = ConfigPhase.RUN_TIME) -public class LambdaHttpConfig { +@ConfigMapping(prefix = "quarkus.lambda-http") +public interface LambdaHttpConfig { /** * If true, runtime will search Cognito JWT claims for "cognito:groups" @@ -13,20 +15,20 @@ public class LambdaHttpConfig { * * True by default */ - @ConfigItem(defaultValue = "true") - public boolean mapCognitoToRoles; + @WithDefault("true") + boolean mapCognitoToRoles(); /** * Cognito claim that contains roles you want to map. Defaults to "cognito:groups" */ - @ConfigItem(defaultValue = "cognito:groups") - public String cognitoRoleClaim; + @WithDefault("cognito:groups") + String cognitoRoleClaim(); /** * Regular expression to locate role values within a Cognito claim string. - * By default it looks for space delimited strings enclosed in brackets + * By default, it looks for space delimited strings enclosed in brackets * "[^\[\] \t]+" */ - @ConfigItem(defaultValue = "[^\\[\\] \\t]+") - public String cognitoClaimMatcher; + @WithDefault("[^\\[\\] \\t]+") + String cognitoClaimMatcher(); } diff --git a/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpRecorder.java b/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpRecorder.java index c5c6a40677b35..616ca65c9c421 100644 --- a/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpRecorder.java +++ b/extensions/amazon-lambda-rest/runtime/src/main/java/io/quarkus/amazon/lambda/http/LambdaHttpRecorder.java @@ -11,7 +11,7 @@ public class LambdaHttpRecorder { public void setConfig(LambdaHttpConfig c) { config = c; - String pattern = c.cognitoClaimMatcher; + String pattern = c.cognitoClaimMatcher(); groupPattern = Pattern.compile(pattern); } } diff --git a/extensions/amazon-lambda/common-deployment/pom.xml b/extensions/amazon-lambda/common-deployment/pom.xml index 6fa00eea9b452..0fd84942a5f72 100644 --- a/extensions/amazon-lambda/common-deployment/pom.xml +++ b/extensions/amazon-lambda/common-deployment/pom.xml @@ -61,9 +61,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/extensions/amazon-lambda/common-deployment/src/main/java/io/quarkus/amazon/lambda/deployment/DevServicesLambdaProcessor.java b/extensions/amazon-lambda/common-deployment/src/main/java/io/quarkus/amazon/lambda/deployment/DevServicesLambdaProcessor.java index c30f5e707a21a..84c3f128445fb 100644 --- a/extensions/amazon-lambda/common-deployment/src/main/java/io/quarkus/amazon/lambda/deployment/DevServicesLambdaProcessor.java +++ b/extensions/amazon-lambda/common-deployment/src/main/java/io/quarkus/amazon/lambda/deployment/DevServicesLambdaProcessor.java @@ -63,7 +63,7 @@ public void startEventServer(LaunchModeBuildItem launchMode, return; if (legacyTestingEnabled()) return; - if (!config.mockEventServer.enabled) { + if (!config.mockEventServer().enabled()) { return; } if (server != null) { @@ -77,8 +77,8 @@ public void startEventServer(LaunchModeBuildItem launchMode, } server = supplier.get(); - int port = launchMode.getLaunchMode() == LaunchMode.TEST ? config.mockEventServer.testPort - : config.mockEventServer.devPort; + int port = launchMode.getLaunchMode() == LaunchMode.TEST ? config.mockEventServer().testPort() + : config.mockEventServer().devPort(); startMode = launchMode.getLaunchMode(); server.start(port); int actualPort = server.getPort(); diff --git a/extensions/amazon-lambda/common-deployment/src/main/java/io/quarkus/amazon/lambda/deployment/LambdaConfig.java b/extensions/amazon-lambda/common-deployment/src/main/java/io/quarkus/amazon/lambda/deployment/LambdaConfig.java index e9dcc8e4c3e61..36c15e3e3a77e 100644 --- a/extensions/amazon-lambda/common-deployment/src/main/java/io/quarkus/amazon/lambda/deployment/LambdaConfig.java +++ b/extensions/amazon-lambda/common-deployment/src/main/java/io/quarkus/amazon/lambda/deployment/LambdaConfig.java @@ -2,13 +2,15 @@ import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; @ConfigRoot(phase = ConfigPhase.BUILD_TIME) -public class LambdaConfig { +@ConfigMapping(prefix = "quarkus.lambda") +public interface LambdaConfig { /** * Configuration for the mock event server that is run * in dev mode and test mode */ - MockEventServerConfig mockEventServer; + MockEventServerConfig mockEventServer(); } diff --git a/extensions/amazon-lambda/common-deployment/src/main/java/io/quarkus/amazon/lambda/deployment/MockEventServerConfig.java b/extensions/amazon-lambda/common-deployment/src/main/java/io/quarkus/amazon/lambda/deployment/MockEventServerConfig.java index 85cde4c70a1fb..c2aa666e9810f 100644 --- a/extensions/amazon-lambda/common-deployment/src/main/java/io/quarkus/amazon/lambda/deployment/MockEventServerConfig.java +++ b/extensions/amazon-lambda/common-deployment/src/main/java/io/quarkus/amazon/lambda/deployment/MockEventServerConfig.java @@ -1,29 +1,27 @@ package io.quarkus.amazon.lambda.deployment; -import io.quarkus.runtime.annotations.ConfigGroup; -import io.quarkus.runtime.annotations.ConfigItem; +import io.smallrye.config.WithDefault; /** * Configuration for the mock event server that is run * in dev mode and test mode */ -@ConfigGroup -public class MockEventServerConfig { +public interface MockEventServerConfig { /** * Setting to true will start event server even if quarkus.devservices.enabled=false */ - @ConfigItem(defaultValue = "true") - public boolean enabled; + @WithDefault("true") + boolean enabled(); /** * Port to access mock event server in dev mode */ - @ConfigItem(defaultValue = "8080") - public int devPort; + @WithDefault("8080") + int devPort(); /** * Port to access mock event server in dev mode */ - @ConfigItem(defaultValue = "8081") - public int testPort; + @WithDefault("8081") + int testPort(); } diff --git a/extensions/amazon-lambda/common-runtime/pom.xml b/extensions/amazon-lambda/common-runtime/pom.xml index eb8a8bab3501b..36b98ed34d715 100644 --- a/extensions/amazon-lambda/common-runtime/pom.xml +++ b/extensions/amazon-lambda/common-runtime/pom.xml @@ -72,9 +72,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/extensions/amazon-lambda/deployment/pom.xml b/extensions/amazon-lambda/deployment/pom.xml index 083bdd130e531..2eb03cfce23ff 100644 --- a/extensions/amazon-lambda/deployment/pom.xml +++ b/extensions/amazon-lambda/deployment/pom.xml @@ -62,9 +62,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/extensions/amazon-lambda/deployment/src/main/java/io/quarkus/amazon/lambda/deployment/AmazonLambdaProcessor.java b/extensions/amazon-lambda/deployment/src/main/java/io/quarkus/amazon/lambda/deployment/AmazonLambdaProcessor.java index d7b631b826e24..6ce046aa7da98 100644 --- a/extensions/amazon-lambda/deployment/src/main/java/io/quarkus/amazon/lambda/deployment/AmazonLambdaProcessor.java +++ b/extensions/amazon-lambda/deployment/src/main/java/io/quarkus/amazon/lambda/deployment/AmazonLambdaProcessor.java @@ -333,7 +333,7 @@ void startPoolLoopDevOrTest(AmazonLambdaRecorder recorder, void recordExpectedExceptions(LambdaBuildTimeConfig config, BuildProducer registerForReflection, AmazonLambdaStaticRecorder recorder) { - Set> classes = config.expectedExceptions.map(Set::copyOf).orElseGet(Set::of); + Set> classes = config.expectedExceptions().map(Set::copyOf).orElseGet(Set::of); classes.stream() .map(clazz -> ReflectiveClassBuildItem.builder(clazz).constructors(false) .reason(getClass().getName() + " expectedExceptions") diff --git a/extensions/amazon-lambda/maven-archetype/src/main/resources/archetype-resources/pom.xml b/extensions/amazon-lambda/maven-archetype/src/main/resources/archetype-resources/pom.xml index 601a9675f432a..fe81817155cd8 100644 --- a/extensions/amazon-lambda/maven-archetype/src/main/resources/archetype-resources/pom.xml +++ b/extensions/amazon-lambda/maven-archetype/src/main/resources/archetype-resources/pom.xml @@ -17,7 +17,7 @@ quarkus-bom io.quarkus 999-SNAPSHOT - 3.5.0 + 3.5.2 diff --git a/extensions/amazon-lambda/runtime/pom.xml b/extensions/amazon-lambda/runtime/pom.xml index 0a565e1dbe5e1..e12340e996247 100644 --- a/extensions/amazon-lambda/runtime/pom.xml +++ b/extensions/amazon-lambda/runtime/pom.xml @@ -51,9 +51,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/extensions/amazon-lambda/runtime/src/main/java/io/quarkus/amazon/lambda/runtime/AmazonLambdaRecorder.java b/extensions/amazon-lambda/runtime/src/main/java/io/quarkus/amazon/lambda/runtime/AmazonLambdaRecorder.java index 49dc6d9d92a2b..6ff6371ae775b 100644 --- a/extensions/amazon-lambda/runtime/src/main/java/io/quarkus/amazon/lambda/runtime/AmazonLambdaRecorder.java +++ b/extensions/amazon-lambda/runtime/src/main/java/io/quarkus/amazon/lambda/runtime/AmazonLambdaRecorder.java @@ -120,12 +120,12 @@ public void chooseHandlerClass(List>> unnam Class> handlerClass = null; Class handlerStreamClass = null; - if (config.handler.isPresent()) { - handlerClass = namedHandlerClasses.get(config.handler.get()); - handlerStreamClass = namedStreamHandlerClasses.get(config.handler.get()); + if (config.handler().isPresent()) { + handlerClass = namedHandlerClasses.get(config.handler().get()); + handlerStreamClass = namedStreamHandlerClasses.get(config.handler().get()); if (handlerClass == null && handlerStreamClass == null) { - String errorMessage = "Unable to find handler class with name " + config.handler.get() + String errorMessage = "Unable to find handler class with name " + config.handler().get() + " make sure there is a handler class in the deployment with the correct @Named annotation"; throw new RuntimeException(errorMessage); } diff --git a/extensions/amazon-lambda/runtime/src/main/java/io/quarkus/amazon/lambda/runtime/LambdaBuildTimeConfig.java b/extensions/amazon-lambda/runtime/src/main/java/io/quarkus/amazon/lambda/runtime/LambdaBuildTimeConfig.java index 4a035af4fb7ee..5bf355cef4911 100644 --- a/extensions/amazon-lambda/runtime/src/main/java/io/quarkus/amazon/lambda/runtime/LambdaBuildTimeConfig.java +++ b/extensions/amazon-lambda/runtime/src/main/java/io/quarkus/amazon/lambda/runtime/LambdaBuildTimeConfig.java @@ -3,12 +3,13 @@ import java.util.List; import java.util.Optional; -import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; -@ConfigRoot(name = "lambda", phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) -public class LambdaBuildTimeConfig { +@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +@ConfigMapping(prefix = "quarkus.lambda") +public interface LambdaBuildTimeConfig { /** * The exception classes expected to be thrown by the handler. @@ -17,6 +18,5 @@ public class LambdaBuildTimeConfig { * but will otherwise be handled normally by the lambda runtime. This is useful for avoiding unnecessary * stack traces while preserving the ability to log unexpected exceptions. */ - @ConfigItem - public Optional>> expectedExceptions; + Optional>> expectedExceptions(); } diff --git a/extensions/amazon-lambda/runtime/src/main/java/io/quarkus/amazon/lambda/runtime/LambdaConfig.java b/extensions/amazon-lambda/runtime/src/main/java/io/quarkus/amazon/lambda/runtime/LambdaConfig.java index 8335b3f3d3a08..0aceb7a4f08ef 100644 --- a/extensions/amazon-lambda/runtime/src/main/java/io/quarkus/amazon/lambda/runtime/LambdaConfig.java +++ b/extensions/amazon-lambda/runtime/src/main/java/io/quarkus/amazon/lambda/runtime/LambdaConfig.java @@ -2,12 +2,13 @@ import java.util.Optional; -import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; @ConfigRoot(phase = ConfigPhase.RUN_TIME) -public class LambdaConfig { +@ConfigMapping(prefix = "quarkus.lambda") +public interface LambdaConfig { /** * The handler name. Handler names are specified on handler classes using the {@link @jakarta.inject.Named} annotation. @@ -18,6 +19,5 @@ public class LambdaConfig { * then the named handler will be used. * */ - @ConfigItem - public Optional handler; + Optional handler(); } diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcProcessor.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcProcessor.java index ff175046a6960..a89b988cb9184 100644 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcProcessor.java +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcProcessor.java @@ -68,16 +68,13 @@ import io.quarkus.arc.runtime.LoggerProducer; import io.quarkus.arc.runtime.appcds.AppCDSRecorder; import io.quarkus.arc.runtime.context.ArcContextProvider; -import io.quarkus.arc.runtime.test.PreloadedTestApplicationClassPredicate; import io.quarkus.bootstrap.BootstrapDebug; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.Feature; -import io.quarkus.deployment.IsTest; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.Consume; -import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Produce; import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.AdditionalApplicationArchiveMarkerBuildItem; @@ -653,27 +650,6 @@ public void signalBeanContainerReady(AppCDSRecorder recorder, PreBeanContainerBu beanContainerProducer.produce(new BeanContainerBuildItem(bi.getValue())); } - @BuildStep(onlyIf = IsTest.class) - public AdditionalBeanBuildItem testApplicationClassPredicateBean() { - // We need to register the bean implementation for TestApplicationClassPredicate - // TestApplicationClassPredicate is used programmatically in the ArC recorder when StartupEvent is fired - return AdditionalBeanBuildItem.unremovableOf(PreloadedTestApplicationClassPredicate.class); - } - - @BuildStep(onlyIf = IsTest.class) - @Record(ExecutionTime.STATIC_INIT) - void initTestApplicationClassPredicateBean(ArcRecorder recorder, BeanContainerBuildItem beanContainer, - BeanDiscoveryFinishedBuildItem beanDiscoveryFinished, - CompletedApplicationClassPredicateBuildItem predicate) { - Set applicationBeanClasses = new HashSet<>(); - for (BeanInfo bean : beanDiscoveryFinished.beanStream().classBeans()) { - if (predicate.test(bean.getBeanClass())) { - applicationBeanClasses.add(bean.getBeanClass().toString()); - } - } - recorder.initTestApplicationClassPredicate(applicationBeanClasses); - } - @BuildStep List marker() { return Arrays.asList(new AdditionalApplicationArchiveMarkerBuildItem("META-INF/beans.xml"), diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcTestSteps.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcTestSteps.java new file mode 100644 index 0000000000000..66c86e0b055e5 --- /dev/null +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcTestSteps.java @@ -0,0 +1,71 @@ +package io.quarkus.arc.deployment; + +import java.util.HashSet; +import java.util.Set; +import java.util.function.Predicate; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTransformation; +import org.jboss.jandex.DotName; + +import io.quarkus.arc.processor.BeanInfo; +import io.quarkus.arc.runtime.ArcRecorder; +import io.quarkus.arc.runtime.test.ActivateSessionContextInterceptor; +import io.quarkus.arc.runtime.test.PreloadedTestApplicationClassPredicate; +import io.quarkus.deployment.IsTest; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.ApplicationClassPredicateBuildItem; + +@BuildSteps(onlyIf = IsTest.class) +public class ArcTestSteps { + + @BuildStep + public void additionalBeans(BuildProducer additionalBeans) { + // We need to register the bean implementation for TestApplicationClassPredicate + // TestApplicationClassPredicate is used programmatically in the ArC recorder when StartupEvent is fired + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(PreloadedTestApplicationClassPredicate.class)); + // In tests, register the ActivateSessionContextInterceptor and ActivateSessionContext interceptor binding + additionalBeans.produce(new AdditionalBeanBuildItem(ActivateSessionContextInterceptor.class)); + additionalBeans.produce(new AdditionalBeanBuildItem("io.quarkus.test.ActivateSessionContext")); + } + + @BuildStep + AnnotationsTransformerBuildItem addInterceptorBinding() { + return new AnnotationsTransformerBuildItem( + AnnotationTransformation.forClasses().whenClass(ActivateSessionContextInterceptor.class).transform(tc -> tc.add( + AnnotationInstance.builder(DotName.createSimple("io.quarkus.test.ActivateSessionContext")).build()))); + } + + // For some reason the annotation literal generated for io.quarkus.test.ActivateSessionContext lives in app class loader. + // This predicates ensures that the generated bean is considered an app class too. + // As a consequence, the type and all methods of ActivateSessionContextInterceptor must be public. + @BuildStep + ApplicationClassPredicateBuildItem appClassPredicate() { + return new ApplicationClassPredicateBuildItem(new Predicate() { + + @Override + public boolean test(String name) { + return name.startsWith(ActivateSessionContextInterceptor.class.getName()); + } + }); + } + + @BuildStep + @Record(ExecutionTime.STATIC_INIT) + void initTestApplicationClassPredicateBean(ArcRecorder recorder, BeanContainerBuildItem beanContainer, + BeanDiscoveryFinishedBuildItem beanDiscoveryFinished, + CompletedApplicationClassPredicateBuildItem predicate) { + Set applicationBeanClasses = new HashSet<>(); + for (BeanInfo bean : beanDiscoveryFinished.beanStream().classBeans()) { + if (predicate.test(bean.getBeanClass())) { + applicationBeanClasses.add(bean.getBeanClass().toString()); + } + } + recorder.initTestApplicationClassPredicate(applicationBeanClasses); + } + +} diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SyntheticBeanBuildItem.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SyntheticBeanBuildItem.java index 0bee6f858918c..91b10940263cf 100644 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SyntheticBeanBuildItem.java +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SyntheticBeanBuildItem.java @@ -32,6 +32,9 @@ public final class SyntheticBeanBuildItem extends MultiBuildItem { /** + * Returns a configurator object allowing for further customization of the synthetic bean. + *

+ * The implementation class is automatically registered as a resulting bean type. * * @param implClazz * @return a new configurator instance @@ -42,6 +45,9 @@ public static ExtendedBeanConfigurator configure(Class implClazz) { } /** + * Returns a configurator object allowing for further customization of the synthetic bean. + *

+ * The implementation class is automatically registered as a resulting bean type. * * @param implClazz * @return a new configurator instance @@ -51,6 +57,32 @@ public static ExtendedBeanConfigurator configure(DotName implClazz) { return new ExtendedBeanConfigurator(implClazz).addType(implClazz); } + /** + * Returns a configurator object allowing for further customization of the synthetic bean. + *

+ * Unlike {@link #configure(Class)}, the implementation class is not registered as a resulting bean type. + * + * @param implClazz + * @return a new configurator instance + * @see ExtendedBeanConfigurator#done() + */ + public static ExtendedBeanConfigurator create(Class implClazz) { + return create(DotName.createSimple(implClazz.getName())); + } + + /** + * Returns a configurator object allowing for further customization of the synthetic bean. + *

+ * Unlike {@link #configure(DotName)}, the implementation class is not registered as a resulting bean type. + * + * @param implClazz + * @return a new configurator instance + * @see ExtendedBeanConfigurator#done() + */ + public static ExtendedBeanConfigurator create(DotName implClazz) { + return new ExtendedBeanConfigurator(implClazz); + } + private final ExtendedBeanConfigurator configurator; SyntheticBeanBuildItem(ExtendedBeanConfigurator configurator) { diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/Client.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/Client.java new file mode 100644 index 0000000000000..2be51f601a58f --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/Client.java @@ -0,0 +1,32 @@ +package io.quarkus.arc.test.context.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import jakarta.enterprise.context.Dependent; +import jakarta.enterprise.context.SessionScoped; +import jakarta.inject.Inject; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ClientProxy; +import io.quarkus.test.ActivateSessionContext; + +@Dependent +class Client { + + @Inject + SimpleBean bean; + + @ActivateSessionContext + public String ping() { + assertTrue(Arc.container().sessionContext().isActive()); + if (bean instanceof ClientProxy proxy) { + assertEquals(SessionScoped.class, proxy.arc_bean().getScope()); + } else { + fail("Not a client proxy"); + } + return bean.ping(); + } + +} diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/SessionContextTest.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/SessionContextTest.java new file mode 100644 index 0000000000000..b45fbfa2979af --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/SessionContextTest.java @@ -0,0 +1,49 @@ +package io.quarkus.arc.test.context.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ManagedContext; +import io.quarkus.test.QuarkusUnitTest; + +public class SessionContextTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot(root -> root + .addClasses(SimpleBean.class, Client.class)); + + @Inject + Client client; + + @Inject + SimpleBean simpleBean; + + @Test + public void testContexts() { + assertFalse(Arc.container().sessionContext().isActive()); + assertNotNull(client.ping()); + assertTrue(SimpleBean.DESTROYED.get()); + assertFalse(Arc.container().sessionContext().isActive()); + SimpleBean.DESTROYED.set(false); + + ManagedContext sessionContext = Arc.container().sessionContext(); + try { + sessionContext.activate(); + String id = simpleBean.ping(); + assertEquals(id, client.ping()); + assertFalse(SimpleBean.DESTROYED.get()); + } finally { + sessionContext.terminate(); + } + assertTrue(SimpleBean.DESTROYED.get()); + } +} diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/SimpleBean.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/SimpleBean.java new file mode 100644 index 0000000000000..77bb8f5f81483 --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/SimpleBean.java @@ -0,0 +1,30 @@ +package io.quarkus.arc.test.context.session; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.SessionScoped; + +@SessionScoped +class SimpleBean { + + static final AtomicBoolean DESTROYED = new AtomicBoolean(); + + private String id; + + @PostConstruct + void init() { + id = UUID.randomUUID().toString(); + } + + public String ping() { + return id; + } + + @PreDestroy + void destroy() { + DESTROYED.set(true); + } +} \ No newline at end of file diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/create/SyntheticBeanBuildItemCreateTest.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/create/SyntheticBeanBuildItemCreateTest.java new file mode 100644 index 0000000000000..9dbcf9e929612 --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/create/SyntheticBeanBuildItemCreateTest.java @@ -0,0 +1,77 @@ +package io.quarkus.arc.test.synthetic.create; + +import java.util.function.Consumer; + +import jakarta.enterprise.inject.Vetoed; + +import org.jboss.jandex.DotName; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcContainer; +import io.quarkus.arc.BeanCreator; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.processor.BuiltinScope; +import io.quarkus.builder.BuildChainBuilder; +import io.quarkus.builder.BuildContext; +import io.quarkus.builder.BuildStep; +import io.quarkus.test.QuarkusUnitTest; + +/** + * Tests that {@link SyntheticBeanBuildItem#create(DotName)} does not add automatically register the param type as bean type + */ +public class SyntheticBeanBuildItemCreateTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(SyntheticBeanBuildItemCreateTest.class, FooCreator.class, FooInterface.class, Foo.class)) + .addBuildChainCustomizer(buildCustomizer()); + + static Consumer buildCustomizer() { + return new Consumer() { + + @Override + public void accept(BuildChainBuilder builder) { + builder.addBuildStep(new BuildStep() { + + @Override + public void execute(BuildContext context) { + context.produce(SyntheticBeanBuildItem.create(Foo.class) + .addType(FooInterface.class) + .scope(BuiltinScope.SINGLETON.getInfo()) + .unremovable() + .creator(FooCreator.class) + .done()); + } + }).produces(SyntheticBeanBuildItem.class).build(); + } + }; + } + + @Test + public void testBeanTypes() { + ArcContainer container = Arc.container(); + Assertions.assertFalse(container.select(Foo.class).isResolvable()); + Assertions.assertTrue(container.select(FooInterface.class).isResolvable()); + } + + @Vetoed + public static class Foo implements FooInterface { + } + + interface FooInterface { + } + + public static class FooCreator implements BeanCreator { + + @Override + public Foo create(SyntheticCreationalContext context) { + return new Foo(); + } + + } +} diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/removeTypes/SyntheticBeanBuildItemRemoveTypesTest.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/removeTypes/SyntheticBeanBuildItemRemoveTypesTest.java new file mode 100644 index 0000000000000..cbe724fbfc236 --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/removeTypes/SyntheticBeanBuildItemRemoveTypesTest.java @@ -0,0 +1,103 @@ +package io.quarkus.arc.test.synthetic.removeTypes; + +import java.util.function.Consumer; + +import org.jboss.jandex.DotName; +import org.jboss.jandex.Type; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcContainer; +import io.quarkus.arc.BeanCreator; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.processor.BuiltinScope; +import io.quarkus.builder.BuildChainBuilder; +import io.quarkus.builder.BuildContext; +import io.quarkus.builder.BuildStep; +import io.quarkus.test.QuarkusUnitTest; + +public class SyntheticBeanBuildItemRemoveTypesTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(SyntheticBeanBuildItemRemoveTypesTest.class, FooCreator.class, FooInterface.class, Foo.class, + FooSubclass.class, Charlie.class, CharlieSubclass.class, CharlieInterface.class, BarInterface.class, + BazInterface.class)) + .addBuildChainCustomizer(buildCustomizer()); + + static Consumer buildCustomizer() { + return new Consumer() { + + @Override + public void accept(BuildChainBuilder builder) { + builder.addBuildStep(new BuildStep() { + + @Override + public void execute(BuildContext context) { + context.produce(SyntheticBeanBuildItem.create(FooSubclass.class) + .addTypeClosure(FooSubclass.class) + .removeTypes(DotName.createSimple(CharlieSubclass.class)) + .removeTypes(DotName.createSimple(FooSubclass.class)) + .removeTypes(Type.create(BazInterface.class)) + .scope(BuiltinScope.SINGLETON.getInfo()) + .unremovable() + .creator(FooCreator.class) + .done()); + } + }).produces(SyntheticBeanBuildItem.class).build(); + } + }; + } + + @Test + public void testRemovingBeanTypes() { + ArcContainer container = Arc.container(); + Assertions.assertTrue(container.select(Foo.class).isResolvable()); + Assertions.assertTrue(container.select(FooInterface.class).isResolvable()); + Assertions.assertTrue(container.select(BarInterface.class).isResolvable()); + Assertions.assertTrue(container.select(Charlie.class).isResolvable()); + Assertions.assertTrue(container.select(CharlieInterface.class).isResolvable()); + + // CharlieSubclass, FooSubclass and BazInterface should not be registered as bean types + Assertions.assertFalse(container.select(CharlieSubclass.class).isResolvable()); + Assertions.assertFalse(container.select(FooSubclass.class).isResolvable()); + Assertions.assertFalse(container.select(BazInterface.class).isResolvable()); + } + + public static class FooSubclass extends Foo implements FooInterface { + } + + public static class Foo extends Charlie implements BazInterface { + } + + public static class CharlieSubclass extends Charlie { + } + + public static class Charlie implements CharlieInterface { + } + + interface CharlieInterface { + } + + interface FooInterface extends BarInterface { + } + + interface BarInterface { + } + + interface BazInterface { + } + + public static class FooCreator implements BeanCreator { + + @Override + public FooSubclass create(SyntheticCreationalContext context) { + return new FooSubclass(); + } + + } +} diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/typeClosure/SyntheticBeanBuildItemAddTypeClosureGenericsTest.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/typeClosure/SyntheticBeanBuildItemAddTypeClosureGenericsTest.java new file mode 100644 index 0000000000000..779aeff0de853 --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/typeClosure/SyntheticBeanBuildItemAddTypeClosureGenericsTest.java @@ -0,0 +1,129 @@ +package io.quarkus.arc.test.synthetic.typeClosure; + +import java.util.function.Consumer; + +import jakarta.enterprise.util.TypeLiteral; + +import org.jboss.jandex.ParameterizedType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcContainer; +import io.quarkus.arc.BeanCreator; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.processor.BuiltinScope; +import io.quarkus.builder.BuildChainBuilder; +import io.quarkus.builder.BuildContext; +import io.quarkus.builder.BuildStep; +import io.quarkus.test.QuarkusUnitTest; + +public class SyntheticBeanBuildItemAddTypeClosureGenericsTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(SyntheticBeanBuildItemAddTypeClosureGenericsTest.class, FooCreator.class, FooInterface.class, + Foo.class, + FooSubclass.class, Charlie.class, CharlieSubclass.class, CharlieInterface.class, BarInterface.class, + BazInterface.class, Alpha.class, Beta.class)) + .addBuildChainCustomizer(buildCustomizer()); + + static Consumer buildCustomizer() { + return new Consumer() { + + @Override + public void accept(BuildChainBuilder builder) { + builder.addBuildStep(new BuildStep() { + + @Override + public void execute(BuildContext context) { + context.produce(SyntheticBeanBuildItem.create(FooSubclass.class) + .addTypeClosure(ParameterizedType.builder(FooSubclass.class).addArgument(Beta.class).build()) + .scope(BuiltinScope.SINGLETON.getInfo()) + .unremovable() + .creator(FooCreator.class) + .done()); + } + }).produces(SyntheticBeanBuildItem.class).build(); + } + }; + } + + @Test + public void testBeanTypesDiscovered() { + ArcContainer container = Arc.container(); + + // Foo/Bar/Baz interfaces should work normally, no generics there + Assertions.assertTrue(container.select(FooInterface.class).isResolvable()); + Assertions.assertTrue(container.select(BarInterface.class).isResolvable()); + Assertions.assertTrue(container.select(BazInterface.class).isResolvable()); + + // FooSubclass is resolvable only as correct parameterized type + Assertions.assertTrue(container.select(new TypeLiteral>() { + }).isResolvable()); + Assertions.assertFalse(container.select(new TypeLiteral>() { + }).isResolvable()); + Assertions.assertFalse(container.select(FooSubclass.class).isResolvable()); + + // Foo type should work only parameterized + Assertions.assertTrue(container.select(new TypeLiteral>() { + }).isResolvable()); + Assertions.assertFalse(container.select(Foo.class).isResolvable()); + + // Foo extends Charlie raw type + // we should be able to perform resolution for raw type but not for a parameterized type + Assertions.assertTrue(container.select(Charlie.class).isResolvable()); + Assertions.assertTrue(container.select(CharlieInterface.class).isResolvable()); + Assertions.assertFalse(container.select(new TypeLiteral>() { + }).isResolvable()); + Assertions.assertFalse(container.select(new TypeLiteral>() { + }).isResolvable()); + + // CharlieSubclass should not be discovered as bean type + Assertions.assertFalse(container.select(CharlieSubclass.class).isResolvable()); + } + + public static class Alpha { + + } + + public static class Beta { + + } + + public static class FooSubclass extends Foo implements FooInterface { + } + + public static class Foo extends Charlie implements BazInterface { + } + + public static class CharlieSubclass extends Charlie { + } + + public static class Charlie implements CharlieInterface { + } + + interface CharlieInterface { + } + + interface FooInterface extends BarInterface { + } + + interface BarInterface { + } + + interface BazInterface { + } + + public static class FooCreator implements BeanCreator> { + + @Override + public FooSubclass create(SyntheticCreationalContext> context) { + return new FooSubclass(); + } + + } +} diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/typeClosure/SyntheticBeanBuildItemAddTypeClosureTest.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/typeClosure/SyntheticBeanBuildItemAddTypeClosureTest.java new file mode 100644 index 0000000000000..3801726f44eb7 --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/typeClosure/SyntheticBeanBuildItemAddTypeClosureTest.java @@ -0,0 +1,100 @@ +package io.quarkus.arc.test.synthetic.typeClosure; + +import java.util.function.Consumer; + +import org.jboss.jandex.DotName; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcContainer; +import io.quarkus.arc.BeanCreator; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.processor.BuiltinScope; +import io.quarkus.builder.BuildChainBuilder; +import io.quarkus.builder.BuildContext; +import io.quarkus.builder.BuildStep; +import io.quarkus.test.QuarkusUnitTest; + +public class SyntheticBeanBuildItemAddTypeClosureTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(SyntheticBeanBuildItemAddTypeClosureTest.class, FooCreator.class, FooInterface.class, Foo.class, + FooSubclass.class, Charlie.class, CharlieSubclass.class, CharlieInterface.class, BarInterface.class, + BazInterface.class)) + .addBuildChainCustomizer(buildCustomizer()); + + static Consumer buildCustomizer() { + return new Consumer() { + + @Override + public void accept(BuildChainBuilder builder) { + builder.addBuildStep(new BuildStep() { + + @Override + public void execute(BuildContext context) { + context.produce(SyntheticBeanBuildItem.create(FooSubclass.class) + .addTypeClosure(FooInterface.class) + .addTypeClosure(DotName.createSimple(Foo.class)) + .scope(BuiltinScope.SINGLETON.getInfo()) + .unremovable() + .creator(FooCreator.class) + .done()); + } + }).produces(SyntheticBeanBuildItem.class).build(); + } + }; + } + + @Test + public void testBeanTypesDiscovered() { + ArcContainer container = Arc.container(); + Assertions.assertTrue(container.select(Foo.class).isResolvable()); + Assertions.assertTrue(container.select(FooInterface.class).isResolvable()); + Assertions.assertTrue(container.select(BarInterface.class).isResolvable()); + Assertions.assertTrue(container.select(Charlie.class).isResolvable()); + Assertions.assertTrue(container.select(CharlieInterface.class).isResolvable()); + Assertions.assertTrue(container.select(BazInterface.class).isResolvable()); + + // Charlie and Foo subclasses should not be registered as bean types + Assertions.assertFalse(container.select(CharlieSubclass.class).isResolvable()); + Assertions.assertFalse(container.select(FooSubclass.class).isResolvable()); + } + + public static class FooSubclass extends Foo implements FooInterface { + } + + public static class Foo extends Charlie implements BazInterface { + } + + public static class CharlieSubclass extends Charlie { + } + + public static class Charlie implements CharlieInterface { + } + + interface CharlieInterface { + } + + interface FooInterface extends BarInterface { + } + + interface BarInterface { + } + + interface BazInterface { + } + + public static class FooCreator implements BeanCreator { + + @Override + public FooSubclass create(SyntheticCreationalContext context) { + return new FooSubclass(); + } + + } +} diff --git a/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/test/ActivateSessionContextInterceptor.java b/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/test/ActivateSessionContextInterceptor.java new file mode 100644 index 0000000000000..ea5452e2babc7 --- /dev/null +++ b/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/test/ActivateSessionContextInterceptor.java @@ -0,0 +1,30 @@ +package io.quarkus.arc.runtime.test; + +import jakarta.annotation.Priority; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ManagedContext; + +// The @ActivateSessionContext interceptor binding is added by the extension +@Interceptor +@Priority(Interceptor.Priority.PLATFORM_BEFORE + 100) +public class ActivateSessionContextInterceptor { + + @AroundInvoke + public Object aroundInvoke(InvocationContext ctx) throws Exception { + ManagedContext sessionContext = Arc.container().sessionContext(); + if (sessionContext.isActive()) { + return ctx.proceed(); + } + try { + sessionContext.activate(); + return ctx.proceed(); + } finally { + sessionContext.terminate(); + } + } + +} diff --git a/extensions/azure-functions/deployment/pom.xml b/extensions/azure-functions/deployment/pom.xml index 10437cac5561b..f01b722b1d4d1 100644 --- a/extensions/azure-functions/deployment/pom.xml +++ b/extensions/azure-functions/deployment/pom.xml @@ -132,9 +132,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsConfig.java b/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsConfig.java index b2994e55bd132..75e3954f397cf 100644 --- a/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsConfig.java +++ b/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsConfig.java @@ -9,7 +9,6 @@ import java.util.Properties; import org.apache.commons.lang3.StringUtils; -import org.jboss.logging.Logger; import com.azure.core.management.AzureEnvironment; import com.microsoft.azure.toolkit.lib.Azure; @@ -31,27 +30,29 @@ import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; /** * Azure Functions configuration. * Most options supported and name similarly to azure-functions-maven-plugin config */ @ConfigRoot(phase = ConfigPhase.BUILD_TIME) -public class AzureFunctionsConfig { +@ConfigMapping(prefix = "quarkus.azure-functions") +public interface AzureFunctionsConfig { /** * App name for azure function project. This is required setting. * * Defaults to the base artifact name */ - @ConfigItem - public Optional appName; + Optional appName(); /** * Azure Resource Group for your Azure Functions */ - @ConfigItem(defaultValue = "quarkus") - public String resourceGroup; + @WithDefault("quarkus") + String resourceGroup(); /** * Specifies the region where your Azure Functions will be hosted; default value is westus. @@ -59,114 +60,105 @@ public class AzureFunctionsConfig { * "https://github.com/microsoft/azure-maven-plugins/wiki/Azure-Functions:-Configuration-Details#supported-regions">Valid * values */ - @ConfigItem(defaultValue = "westus") - public String region; + @WithDefault("westus") + String region(); /** * Specifies whether to disable application insights for your function app */ - @ConfigItem(defaultValue = "false") - public boolean disableAppInsights; + @WithDefault("false") + boolean disableAppInsights(); /** * Specifies the instrumentation key of application insights which will bind to your function app */ - @ConfigItem - public Optional appInsightsKey; + Optional appInsightsKey(); - public RuntimeConfig runtime; + RuntimeConfig runtime(); - public AuthConfig auth; + AuthConfig auth(); /** * Specifies the name of the existing App Service Plan when you do not want to create a new one. */ - @ConfigItem(defaultValue = "java-functions-app-service-plan") - public String appServicePlanName; + @WithDefault("java-functions-app-service-plan") + String appServicePlanName(); /** * The app service plan resource group. */ - @ConfigItem - public Optional appServicePlanResourceGroup; + Optional appServicePlanResourceGroup(); /** * Azure subscription id. Required only if there are more than one subscription in your account */ - @ConfigItem - public Optional subscriptionId; + Optional subscriptionId(); /** * The pricing tier. */ - @ConfigItem - public Optional pricingTier; + Optional pricingTier(); /** * Port to run azure function in local runtime. * Will default to quarkus.http.test-port or 8081 */ - @ConfigItem - public Optional funcPort; + Optional funcPort(); /** * Config String for local debug */ - @ConfigItem(defaultValue = "transport=dt_socket,server=y,suspend=n,address=5005") - public String localDebugConfig; + @WithDefault("transport=dt_socket,server=y,suspend=n,address=5005") + String localDebugConfig(); /** * Specifies the application settings for your Azure Functions, which are defined in name-value pairs */ @ConfigItem @ConfigDocMapKey("setting-name") - public Map appSettings = Collections.emptyMap(); + Map appSettings = Collections.emptyMap(); @ConfigGroup - public static class RuntimeConfig { + interface RuntimeConfig { /** * Valid values are linux, windows, and docker */ - @ConfigItem(defaultValue = "linux") - public String os; + @WithDefault("linux") + String os(); /** * Valid values are 8, 11, and 17 */ - @ConfigItem(defaultValue = "11") - public String javaVersion; + @WithDefault("11") + String javaVersion(); /** * URL of docker image if deploying via docker */ - @ConfigItem - public Optional image; + Optional image(); /** * If using docker, url of registry */ - @ConfigItem - public Optional registryUrl; + Optional registryUrl(); } - public FunctionAppConfig toFunctionAppConfig(String subscriptionId, String appName) { + default FunctionAppConfig toFunctionAppConfig(String subscriptionId, String appName) { Map appSettings = this.appSettings; if (appSettings.isEmpty()) { appSettings = new HashMap<>(); appSettings.put("FUNCTIONS_EXTENSION_VERSION", "~4"); } return (FunctionAppConfig) new FunctionAppConfig() - .disableAppInsights(disableAppInsights) - .appInsightsKey(appInsightsKey.orElse(null)) - .appInsightsInstance(appInsightsKey.orElse(null)) + .disableAppInsights(disableAppInsights()) + .appInsightsKey(appInsightsKey().orElse(null)) + .appInsightsInstance(appInsightsKey().orElse(null)) .subscriptionId(subscriptionId) - .resourceGroup(resourceGroup) + .resourceGroup(resourceGroup()) .appName(appName) - .servicePlanName(appServicePlanName) - .servicePlanResourceGroup(appServicePlanResourceGroup.orElse(null)) - //.deploymentSlotName(getDeploymentSlotName()) - //.deploymentSlotConfigurationSource(getDeploymentSlotConfigurationSource()) + .servicePlanName(appServicePlanName()) + .servicePlanResourceGroup(appServicePlanResourceGroup().orElse(null)) .pricingTier(getParsedPricingTier(subscriptionId)) .region(getParsedRegion()) .runtime(getRuntimeConfig(subscriptionId)) @@ -174,42 +166,42 @@ public FunctionAppConfig toFunctionAppConfig(String subscriptionId, String appNa } private PricingTier getParsedPricingTier(String subscriptionId) { - return Optional.ofNullable(this.pricingTier.orElse(null)).map(PricingTier::fromString) + return Optional.ofNullable(this.pricingTier().orElse(null)).map(PricingTier::fromString) .orElseGet(() -> Optional.ofNullable(getServicePlan(subscriptionId)).map(AppServicePlan::getPricingTier) .orElse(null)); } private AppServicePlan getServicePlan(String subscriptionId) { - final String servicePlan = this.appServicePlanName; - final String servicePlanGroup = StringUtils.firstNonBlank(this.appServicePlanResourceGroup.orElse(null), - this.resourceGroup); + final String servicePlan = this.appServicePlanName(); + final String servicePlanGroup = StringUtils.firstNonBlank(this.appServicePlanResourceGroup().orElse(null), + this.resourceGroup()); return StringUtils.isAnyBlank(subscriptionId, servicePlan, servicePlanGroup) ? null : Azure.az(AzureAppService.class).plans(subscriptionId).get(servicePlan, servicePlanGroup); } private com.microsoft.azure.toolkit.lib.appservice.config.RuntimeConfig getRuntimeConfig(String subscriptionId) { - final RuntimeConfig runtime = this.runtime; + final RuntimeConfig runtime = this.runtime(); if (runtime == null) { return null; } - final OperatingSystem os = Optional.ofNullable(runtime.os).map(OperatingSystem::fromString) + final OperatingSystem os = Optional.ofNullable(runtime.os()).map(OperatingSystem::fromString) .orElseGet( () -> Optional.ofNullable(getServicePlan(subscriptionId)).map(AppServicePlan::getOperatingSystem) .orElse(null)); - final JavaVersion javaVersion = Optional.ofNullable(runtime.javaVersion).map(JavaVersion::fromString).orElse(null); + final JavaVersion javaVersion = Optional.ofNullable(runtime.javaVersion()).map(JavaVersion::fromString).orElse(null); final com.microsoft.azure.toolkit.lib.appservice.config.RuntimeConfig result = new com.microsoft.azure.toolkit.lib.appservice.config.RuntimeConfig() .os(os) .javaVersion(javaVersion).webContainer(WebContainer.JAVA_OFF) - .image(runtime.image.orElse(null)).registryUrl(runtime.registryUrl.orElse(null)); + .image(runtime.image().orElse(null)).registryUrl(runtime.registryUrl().orElse(null)); return result; } private Region getParsedRegion() { - return Optional.ofNullable(region).map(Region::fromName).orElse(null); + return Optional.ofNullable(region()).map(Region::fromName).orElse(null); } @ConfigGroup - public static class AuthConfig { + interface AuthConfig { /** * Description of each type can be found @@ -234,28 +226,23 @@ public static class AuthConfig { * * Defaults to "azure_cli" for authentication */ - @ConfigItem(defaultValue = "azure_cli") - public String type; + @WithDefault("azure_cli") + String type(); /** * Filesystem path to properties file if using file type */ - @ConfigItem - public Optional path; + Optional path(); /** * Client or App Id required if using managed_identity type */ - @ConfigItem - public Optional client; + Optional client(); /** - * Tenant Id required if using oauth2 or device_code type + * Tenant ID required if using oauth2 or device_code type */ - @ConfigItem - public Optional tenant; - - private static final Logger log = Logger.getLogger(AzureFunctionsConfig.class); + Optional tenant(); private static String findValue(Properties props, String key) { if (props.contains(key)) @@ -301,15 +288,15 @@ private static AuthConfiguration fromFile(Optional path) { } } - public AuthConfiguration toAuthConfiguration() { + default AuthConfiguration toAuthConfiguration() { try { - if (this.type.equalsIgnoreCase("file")) { - return fromFile(this.path); + if (this.type().equalsIgnoreCase("file")) { + return fromFile(this.path()); } - final AuthType type = AuthType.parseAuthType(this.type); + final AuthType type = AuthType.parseAuthType(this.type()); final AuthConfiguration authConfiguration = new AuthConfiguration(type); - authConfiguration.setClient(client.orElse(null)); - authConfiguration.setTenant(tenant.orElse(null)); + authConfiguration.setClient(client().orElse(null)); + authConfiguration.setTenant(tenant().orElse(null)); authConfiguration.setEnvironment(AzureEnvironmentUtils.azureEnvironmentToString(AzureEnvironment.AZURE)); return authConfiguration; } catch (InvalidConfigurationException e) { diff --git a/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsDeployCommand.java b/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsDeployCommand.java index aff10a43c92f2..544240480aead 100644 --- a/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsDeployCommand.java +++ b/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsDeployCommand.java @@ -167,22 +167,22 @@ protected void validateParameters(AzureFunctionsConfig config, String appName) t throw new BuildException(INVALID_APP_NAME); } // resource group - if (StringUtils.isBlank(config.resourceGroup)) { + if (StringUtils.isBlank(config.resourceGroup())) { throw new BuildException(EMPTY_RESOURCE_GROUP); } - if (config.resourceGroup.endsWith(".") || !config.resourceGroup.matches(RESOURCE_GROUP_PATTERN)) { + if (config.resourceGroup().endsWith(".") || !config.resourceGroup().matches(RESOURCE_GROUP_PATTERN)) { throw new BuildException(INVALID_RESOURCE_GROUP_NAME); } // asp name & resource group - if (StringUtils.isNotEmpty(config.appServicePlanName) - && !config.appServicePlanName.matches(APP_SERVICE_PLAN_NAME_PATTERN)) { + if (StringUtils.isNotEmpty(config.appServicePlanName()) + && !config.appServicePlanName().matches(APP_SERVICE_PLAN_NAME_PATTERN)) { throw new BuildException(String.format(INVALID_SERVICE_PLAN_NAME, APP_SERVICE_PLAN_NAME_PATTERN)); } - if (config.appServicePlanResourceGroup.isPresent() - && StringUtils.isNotEmpty(config.appServicePlanResourceGroup.orElse(null)) + if (config.appServicePlanResourceGroup().isPresent() + && StringUtils.isNotEmpty(config.appServicePlanResourceGroup().orElse(null)) && - (config.appServicePlanResourceGroup.orElse(null).endsWith(".") - || !config.appServicePlanResourceGroup.orElse(null).matches(RESOURCE_GROUP_PATTERN))) { + (config.appServicePlanResourceGroup().orElse(null).endsWith(".") + || !config.appServicePlanResourceGroup().orElse(null).matches(RESOURCE_GROUP_PATTERN))) { throw new BuildException(INVALID_SERVICE_PLAN_RESOURCE_GROUP_NAME); } // slot name @@ -196,26 +196,26 @@ protected void validateParameters(AzureFunctionsConfig config, String appName) t * */ // region - if (StringUtils.isNotEmpty(config.region) && Region.fromName(config.region).isExpandedValue()) { - log.warn(String.format(EXPANDABLE_REGION_WARNING, config.region)); + if (StringUtils.isNotEmpty(config.region()) && Region.fromName(config.region()).isExpandedValue()) { + log.warn(String.format(EXPANDABLE_REGION_WARNING, config.region())); } // os - if (StringUtils.isNotEmpty(config.runtime.os) && OperatingSystem.fromString(config.runtime.os) == null) { + if (StringUtils.isNotEmpty(config.runtime().os()) && OperatingSystem.fromString(config.runtime().os()) == null) { throw new BuildException(INVALID_OS); } // java version - if (StringUtils.isNotEmpty(config.runtime.javaVersion) - && JavaVersion.fromString(config.runtime.javaVersion).isExpandedValue()) { - log.warn(String.format(EXPANDABLE_JAVA_VERSION_WARNING, config.runtime.javaVersion)); + if (StringUtils.isNotEmpty(config.runtime().javaVersion()) + && JavaVersion.fromString(config.runtime().javaVersion()).isExpandedValue()) { + log.warn(String.format(EXPANDABLE_JAVA_VERSION_WARNING, config.runtime().javaVersion())); } // pricing tier - if (config.pricingTier.isPresent() && StringUtils.isNotEmpty(config.pricingTier.orElse(null)) - && PricingTier.fromString(config.pricingTier.orElse(null)).isExpandedValue()) { - log.warn(String.format(EXPANDABLE_PRICING_TIER_WARNING, config.pricingTier.orElse(null))); + if (config.pricingTier().isPresent() && StringUtils.isNotEmpty(config.pricingTier().orElse(null)) + && PricingTier.fromString(config.pricingTier().orElse(null)).isExpandedValue()) { + log.warn(String.format(EXPANDABLE_PRICING_TIER_WARNING, config.pricingTier().orElse(null))); } // docker image - if (OperatingSystem.fromString(config.runtime.os) == OperatingSystem.DOCKER - && StringUtils.isEmpty(config.runtime.image.orElse(null))) { + if (OperatingSystem.fromString(config.runtime().os()) == OperatingSystem.DOCKER + && StringUtils.isEmpty(config.runtime().image().orElse(null))) { throw new BuildException(EMPTY_IMAGE_NAME); } } @@ -226,9 +226,9 @@ protected void validateParameters(AzureFunctionsConfig config, String appName) t protected AzureAppService initAzureAppServiceClient(AzureFunctionsConfig config) throws BuildException { if (appServiceClient == null) { - final Account account = loginAzure(config.auth); + final Account account = loginAzure(config.auth()); final List subscriptions = account.getSubscriptions(); - final String targetSubscriptionId = getTargetSubscriptionId(config.subscriptionId.orElse(null), subscriptions, + final String targetSubscriptionId = getTargetSubscriptionId(config.subscriptionId().orElse(null), subscriptions, account.getSelectedSubscriptions()); checkSubscription(subscriptions, targetSubscriptionId); com.microsoft.azure.toolkit.lib.Azure.az(AzureAccount.class).account() diff --git a/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsProcessor.java b/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsProcessor.java index 04ef96151d917..438d5a70fe8f6 100644 --- a/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsProcessor.java +++ b/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsProcessor.java @@ -71,7 +71,7 @@ FeatureBuildItem feature() { @BuildStep AzureFunctionsAppNameBuildItem appName(OutputTargetBuildItem output, AzureFunctionsConfig functionsConfig) { - String appName = functionsConfig.appName.orElse(output.getBaseName()); + String appName = functionsConfig.appName().orElse(output.getBaseName()); return new AzureFunctionsAppNameBuildItem(appName); } diff --git a/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsRunCommand.java b/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsRunCommand.java index 7797fd7b49901..890e08a6f7ec5 100644 --- a/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsRunCommand.java +++ b/extensions/azure-functions/deployment/src/main/java/io/quarkus/azure/functions/deployment/AzureFunctionsRunCommand.java @@ -77,15 +77,15 @@ protected String getCheckRuntimeCommand() { protected String getStartFunctionHostCommand(AzureFunctionsConfig azureConfig) { int funcPort; - if (azureConfig.funcPort.isPresent()) { - funcPort = azureConfig.funcPort.get(); + if (azureConfig.funcPort().isPresent()) { + funcPort = azureConfig.funcPort().get(); } else { Config config = ConfigProviderResolver.instance().getConfig(); funcPort = config.getValue("quarkus.http.test-port", OptionalInt.class).orElse(8081); } final String enableDebug = System.getProperty("enableDebug"); if (StringUtils.isNotEmpty(enableDebug) && enableDebug.equalsIgnoreCase("true")) { - return String.format(FUNC_HOST_START_WITH_DEBUG_CMD, funcPort, azureConfig.localDebugConfig); + return String.format(FUNC_HOST_START_WITH_DEBUG_CMD, funcPort, azureConfig.localDebugConfig()); } else { return String.format(FUNC_HOST_START_CMD, funcPort); } diff --git a/extensions/azure-functions/runtime/pom.xml b/extensions/azure-functions/runtime/pom.xml index 5440764ba4778..0901929c5192b 100644 --- a/extensions/azure-functions/runtime/pom.xml +++ b/extensions/azure-functions/runtime/pom.xml @@ -53,9 +53,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/extensions/devservices/deployment/src/main/java/io/quarkus/devservices/deployment/DevServicesProcessor.java b/extensions/devservices/deployment/src/main/java/io/quarkus/devservices/deployment/DevServicesProcessor.java index da9de6071daba..48b2e42fbd829 100644 --- a/extensions/devservices/deployment/src/main/java/io/quarkus/devservices/deployment/DevServicesProcessor.java +++ b/extensions/devservices/deployment/src/main/java/io/quarkus/devservices/deployment/DevServicesProcessor.java @@ -213,10 +213,10 @@ private static String[] getNetworks(Container container) { return networks.entrySet().stream() .map(e -> { List aliases = e.getValue().getAliases(); - if (aliases == null) { + if (aliases == null || aliases.isEmpty()) { return e.getKey(); } - return e.getKey() + " (" + String.join(",", aliases) + ")"; + return e.getKey() + " (" + String.join(", ", aliases) + ")"; }) .toArray(String[]::new); } diff --git a/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesRequiredBuildItem.java b/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesRequiredBuildItem.java index 49cd131711bb1..5093a9f369185 100644 --- a/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesRequiredBuildItem.java +++ b/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesRequiredBuildItem.java @@ -4,10 +4,11 @@ import java.util.Arrays; import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; -import java.util.stream.Stream; import org.jboss.logging.Logger; import org.keycloak.representations.idm.RealmRepresentation; @@ -24,6 +25,7 @@ public final class KeycloakDevServicesRequiredBuildItem extends MultiBuildItem { private static final Logger LOG = Logger.getLogger(KeycloakDevServicesProcessor.class); public static final String OIDC_AUTH_SERVER_URL_CONFIG_KEY = "quarkus.oidc.auth-server-url"; + private static final String OIDC_PROVIDER_CONFIG_KEY = "quarkus.oidc.provider"; private final KeycloakDevServicesConfigurator devServicesConfigurator; private final String authServerUrl; @@ -39,8 +41,17 @@ String getAuthServerUrl() { } public static KeycloakDevServicesRequiredBuildItem of(KeycloakDevServicesConfigurator devServicesConfigurator, - String authServerUrl, String... dontStartConfigProperties) { - if (shouldStartDevService(dontStartConfigProperties, authServerUrl)) { + String authServerUrl, String... additionalDontStartConfigProperties) { + final Set dontStartConfigProperties = new HashSet<>(Arrays.asList(additionalDontStartConfigProperties)); + dontStartConfigProperties.add(authServerUrl); + dontStartConfigProperties.add(OIDC_AUTH_SERVER_URL_CONFIG_KEY); + dontStartConfigProperties.add(OIDC_PROVIDER_CONFIG_KEY); + return of(devServicesConfigurator, authServerUrl, dontStartConfigProperties); + } + + private static KeycloakDevServicesRequiredBuildItem of(KeycloakDevServicesConfigurator devServicesConfigurator, + String authServerUrl, Set dontStartConfigProperties) { + if (shouldStartDevService(dontStartConfigProperties)) { return new KeycloakDevServicesRequiredBuildItem(devServicesConfigurator, authServerUrl); } return null; @@ -69,10 +80,8 @@ public void customizeDefaultRealm(RealmRepresentation realmRepresentation) { }; } - private static boolean shouldStartDevService(String[] dontStartConfigProperties, String authServerUrl) { - return Stream - .concat(Stream.of(authServerUrl), Arrays.stream(dontStartConfigProperties)) - .allMatch(KeycloakDevServicesRequiredBuildItem::shouldStartDevService); + private static boolean shouldStartDevService(Set dontStartConfigProperties) { + return dontStartConfigProperties.stream().allMatch(KeycloakDevServicesRequiredBuildItem::shouldStartDevService); } private static boolean shouldStartDevService(String dontStartConfigProperty) { diff --git a/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/deployment/FlywayProcessor.java b/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/deployment/FlywayProcessor.java index bf5a400c5610d..76dc5882235b2 100644 --- a/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/deployment/FlywayProcessor.java +++ b/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/deployment/FlywayProcessor.java @@ -61,7 +61,6 @@ import io.quarkus.deployment.builditem.nativeimage.NativeImageResourceBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyBuildItem; -import io.quarkus.deployment.logging.LoggingSetupBuildItem; import io.quarkus.deployment.recording.RecorderContext; import io.quarkus.flyway.FlywayDataSource; import io.quarkus.flyway.runtime.FlywayBuildTimeConfig; @@ -184,7 +183,6 @@ private void addJavaMigrations(Collection candidates, RecorderContext @BuildStep @Produce(SyntheticBeansRuntimeInitBuildItem.class) - @Consume(LoggingSetupBuildItem.class) @Record(ExecutionTime.RUNTIME_INIT) void createBeans(FlywayRecorder recorder, List jdbcDataSourceBuildItems, diff --git a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayCreator.java b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayCreator.java index bcdbb76e0361d..9401b42d184fe 100644 --- a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayCreator.java +++ b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayCreator.java @@ -117,7 +117,6 @@ public Flyway createFlyway(DataSource dataSource) { } configure.ignoreMigrationPatterns(ignoreMigrationPatterns); - configure.cleanOnValidationError(flywayRuntimeConfig.cleanOnValidationError); configure.outOfOrder(flywayRuntimeConfig.outOfOrder); if (flywayRuntimeConfig.baselineVersion.isPresent()) { configure.baselineVersion(flywayRuntimeConfig.baselineVersion.get()); diff --git a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayDataSourceRuntimeConfig.java b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayDataSourceRuntimeConfig.java index 772ba52458280..ed0c82aaa2a76 100644 --- a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayDataSourceRuntimeConfig.java +++ b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayDataSourceRuntimeConfig.java @@ -131,12 +131,6 @@ public static FlywayDataSourceRuntimeConfig defaultConfig() { @ConfigItem public boolean cleanDisabled; - /** - * true to automatically call clean when a validation error occurs, false otherwise. - */ - @ConfigItem - public boolean cleanOnValidationError; - /** * true to execute Flyway automatically when the application starts, false otherwise. * diff --git a/extensions/flyway/runtime/src/test/java/io/quarkus/flyway/runtime/FlywayCreatorTest.java b/extensions/flyway/runtime/src/test/java/io/quarkus/flyway/runtime/FlywayCreatorTest.java index 17764887791ed..4995671779b3f 100644 --- a/extensions/flyway/runtime/src/test/java/io/quarkus/flyway/runtime/FlywayCreatorTest.java +++ b/extensions/flyway/runtime/src/test/java/io/quarkus/flyway/runtime/FlywayCreatorTest.java @@ -222,22 +222,6 @@ void testIgnoreFutureMigrations() { assertTrue(ValidatePatternUtils.isFutureIgnored(createdFlywayConfig().getIgnoreMigrationPatterns())); } - @Test - @DisplayName("cleanOnValidationError defaults to false and is correctly set") - void testCleanOnValidationError() { - creator = new FlywayCreator(runtimeConfig, buildConfig); - assertEquals(runtimeConfig.cleanOnValidationError, createdFlywayConfig().isCleanOnValidationError()); - assertFalse(runtimeConfig.cleanOnValidationError); - - runtimeConfig.cleanOnValidationError = false; - creator = new FlywayCreator(runtimeConfig, buildConfig); - assertFalse(createdFlywayConfig().isCleanOnValidationError()); - - runtimeConfig.cleanOnValidationError = true; - creator = new FlywayCreator(runtimeConfig, buildConfig); - assertTrue(createdFlywayConfig().isCleanOnValidationError()); - } - @ParameterizedTest @MethodSource("validateOnMigrateOverwritten") @DisplayName("validate on migrate overwritten in configuration") diff --git a/extensions/funqy/funqy-amazon-lambda/maven-archetype/src/main/resources/archetype-resources/pom.xml b/extensions/funqy/funqy-amazon-lambda/maven-archetype/src/main/resources/archetype-resources/pom.xml index a8f4423f490e9..3f395b13a370c 100644 --- a/extensions/funqy/funqy-amazon-lambda/maven-archetype/src/main/resources/archetype-resources/pom.xml +++ b/extensions/funqy/funqy-amazon-lambda/maven-archetype/src/main/resources/archetype-resources/pom.xml @@ -17,7 +17,7 @@ quarkus-bom io.quarkus 999-SNAPSHOT - 3.5.0 + 3.5.2 diff --git a/extensions/grpc/codegen/src/main/java/io/quarkus/grpc/deployment/GrpcCodeGen.java b/extensions/grpc/codegen/src/main/java/io/quarkus/grpc/codegen/GrpcCodeGen.java similarity index 99% rename from extensions/grpc/codegen/src/main/java/io/quarkus/grpc/deployment/GrpcCodeGen.java rename to extensions/grpc/codegen/src/main/java/io/quarkus/grpc/codegen/GrpcCodeGen.java index d4e67796e2420..f17358b1bf27c 100644 --- a/extensions/grpc/codegen/src/main/java/io/quarkus/grpc/deployment/GrpcCodeGen.java +++ b/extensions/grpc/codegen/src/main/java/io/quarkus/grpc/codegen/GrpcCodeGen.java @@ -1,4 +1,4 @@ -package io.quarkus.grpc.deployment; +package io.quarkus.grpc.codegen; import static java.lang.Boolean.FALSE; import static java.lang.Boolean.TRUE; diff --git a/extensions/grpc/codegen/src/main/java/io/quarkus/grpc/deployment/GrpcPostProcessing.java b/extensions/grpc/codegen/src/main/java/io/quarkus/grpc/codegen/GrpcPostProcessing.java similarity index 99% rename from extensions/grpc/codegen/src/main/java/io/quarkus/grpc/deployment/GrpcPostProcessing.java rename to extensions/grpc/codegen/src/main/java/io/quarkus/grpc/codegen/GrpcPostProcessing.java index abe19f5de0934..72406fd7c1565 100644 --- a/extensions/grpc/codegen/src/main/java/io/quarkus/grpc/deployment/GrpcPostProcessing.java +++ b/extensions/grpc/codegen/src/main/java/io/quarkus/grpc/codegen/GrpcPostProcessing.java @@ -1,4 +1,4 @@ -package io.quarkus.grpc.deployment; +package io.quarkus.grpc.codegen; import java.io.File; import java.nio.file.Path; diff --git a/extensions/grpc/codegen/src/main/resources/META-INF/services/io.quarkus.deployment.CodeGenProvider b/extensions/grpc/codegen/src/main/resources/META-INF/services/io.quarkus.deployment.CodeGenProvider index 79d063f5e2df5..a2c6cd0c7d022 100644 --- a/extensions/grpc/codegen/src/main/resources/META-INF/services/io.quarkus.deployment.CodeGenProvider +++ b/extensions/grpc/codegen/src/main/resources/META-INF/services/io.quarkus.deployment.CodeGenProvider @@ -1 +1 @@ -io.quarkus.grpc.deployment.GrpcCodeGen \ No newline at end of file +io.quarkus.grpc.codegen.GrpcCodeGen \ No newline at end of file diff --git a/extensions/grpc/deployment/src/test/java/io/quarkus/grpc/client/interceptors/ClientInterceptorConstructorRegistrationTest.java b/extensions/grpc/deployment/src/test/java/io/quarkus/grpc/client/interceptors/ClientInterceptorConstructorRegistrationTest.java new file mode 100644 index 0000000000000..d5148fa9914a9 --- /dev/null +++ b/extensions/grpc/deployment/src/test/java/io/quarkus/grpc/client/interceptors/ClientInterceptorConstructorRegistrationTest.java @@ -0,0 +1,61 @@ +package io.quarkus.grpc.client.interceptors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.grpc.examples.helloworld.Greeter; +import io.grpc.examples.helloworld.GreeterBean; +import io.grpc.examples.helloworld.GreeterGrpc; +import io.grpc.examples.helloworld.HelloReply; +import io.grpc.examples.helloworld.HelloReplyOrBuilder; +import io.grpc.examples.helloworld.HelloRequest; +import io.grpc.examples.helloworld.HelloRequestOrBuilder; +import io.grpc.examples.helloworld.MutinyGreeterGrpc; +import io.quarkus.grpc.GrpcClient; +import io.quarkus.grpc.RegisterClientInterceptor; +import io.quarkus.grpc.server.services.MutinyHelloService; +import io.quarkus.test.QuarkusUnitTest; + +public class ClientInterceptorConstructorRegistrationTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest().setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .addClasses(MutinyHelloService.class, MyThirdClientInterceptor.class, MyLastClientInterceptor.class, + Calls.class, + GreeterGrpc.class, Greeter.class, GreeterBean.class, HelloRequest.class, HelloReply.class, + MutinyGreeterGrpc.class, + HelloRequestOrBuilder.class, HelloReplyOrBuilder.class)) + .withConfigurationResource("hello-config.properties"); + private static final Logger log = LoggerFactory.getLogger(ClientInterceptorConstructorRegistrationTest.class); + + private GreeterGrpc.GreeterBlockingStub client; + + public ClientInterceptorConstructorRegistrationTest( + @RegisterClientInterceptor(MyLastClientInterceptor.class) @RegisterClientInterceptor(MyThirdClientInterceptor.class) @GrpcClient("hello-service") GreeterGrpc.GreeterBlockingStub client) { + this.client = client; + } + + @Test + public void testInterceptorRegistration() { + Calls.LIST.clear(); + + HelloReply reply = client + .sayHello(HelloRequest.newBuilder().setName("neo").build()); + assertThat(reply.getMessage()).isEqualTo("Hello neo"); + + List calls = Calls.LIST; + assertEquals(2, calls.size()); + assertEquals(MyThirdClientInterceptor.class.getName(), calls.get(0)); + assertEquals(MyLastClientInterceptor.class.getName(), calls.get(1)); + } +} diff --git a/extensions/grpc/stubs/pom.xml b/extensions/grpc/stubs/pom.xml index d3a387d227220..83fd66307ca75 100644 --- a/extensions/grpc/stubs/pom.xml +++ b/extensions/grpc/stubs/pom.xml @@ -127,7 +127,7 @@ ${project.build.directory}/generated-sources/protobuf/grpc-java - io.quarkus.grpc.deployment.GrpcPostProcessing + io.quarkus.grpc.codegen.GrpcPostProcessing diff --git a/extensions/info/deployment/src/main/java/io/quarkus/info/deployment/GitUtil.java b/extensions/info/deployment/src/main/java/io/quarkus/info/deployment/GitUtil.java new file mode 100644 index 0000000000000..e6aa16603a5a3 --- /dev/null +++ b/extensions/info/deployment/src/main/java/io/quarkus/info/deployment/GitUtil.java @@ -0,0 +1,30 @@ +package io.quarkus.info.deployment; + +class GitUtil { + + static String sanitizeRemoteUrl(String remoteUrl) { + if (remoteUrl == null || remoteUrl.isBlank()) { + return null; + } + + String sanitizedRemoteUrl = remoteUrl.trim(); + if (sanitizedRemoteUrl.startsWith("https://")) { + int atSign = sanitizedRemoteUrl.indexOf('@'); + if (atSign > 0) { + sanitizedRemoteUrl = "https://" + sanitizedRemoteUrl.substring(atSign + 1); + } + } else if (sanitizedRemoteUrl.startsWith("http://")) { + int atSign = sanitizedRemoteUrl.indexOf('@'); + if (atSign > 0) { + sanitizedRemoteUrl = "http://" + sanitizedRemoteUrl.substring(atSign + 1); + } + } else { + int atSign = sanitizedRemoteUrl.indexOf('@'); + if (atSign > 0) { + sanitizedRemoteUrl = sanitizedRemoteUrl.substring(atSign + 1); + } + } + + return sanitizedRemoteUrl; + } +} diff --git a/extensions/info/deployment/src/main/java/io/quarkus/info/deployment/InfoProcessor.java b/extensions/info/deployment/src/main/java/io/quarkus/info/deployment/InfoProcessor.java index c31f9dae6c19d..135751323adef 100644 --- a/extensions/info/deployment/src/main/java/io/quarkus/info/deployment/InfoProcessor.java +++ b/extensions/info/deployment/src/main/java/io/quarkus/info/deployment/InfoProcessor.java @@ -4,13 +4,13 @@ import java.io.File; import java.net.InetAddress; +import java.time.Instant; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.Collection; -import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.TimeZone; import java.util.stream.Collectors; import jakarta.enterprise.context.ApplicationScoped; @@ -34,6 +34,7 @@ import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.ApplicationInfoBuildItem; import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; import io.quarkus.deployment.pkg.builditem.OutputTargetBuildItem; import io.quarkus.info.BuildInfo; @@ -70,11 +71,10 @@ void gitInfo(InfoBuildTimeConfig config, log.debug("Project is not checked in to git"); return; } - try (Repository repository = repositoryBuilder.build()) { + try (Repository repository = repositoryBuilder.build(); + Git git = Git.wrap(repository)) { - RevCommit latestCommit = new Git(repository).log().setMaxCount(1).call().iterator().next(); - Date commitDate = new Date(latestCommit.getCommitTime() * 1000L); - TimeZone commitTimeZone = TimeZone.getDefault(); + RevCommit latestCommit = git.log().setMaxCount(1).call().iterator().next(); boolean addFullInfo = config.git().mode() == InfoBuildTimeConfig.Git.Mode.FULL; @@ -85,16 +85,17 @@ void gitInfo(InfoBuildTimeConfig config, Map commit = new LinkedHashMap<>(); String latestCommitId = latestCommit.getName(); commit.put("id", latestCommitId); - String latestCommitTime = formatDate(commitDate, commitTimeZone); + String latestCommitTime = formatDate(Instant.ofEpochSecond(latestCommit.getCommitTime()), ZoneId.systemDefault()); commit.put("time", latestCommitTime); if (addFullInfo) { PersonIdent authorIdent = latestCommit.getAuthorIdent(); - commit.put("author", Map.of("time", formatDate(authorIdent.getWhen(), authorIdent.getTimeZone()))); + commit.put("author", Map.of("time", formatDate(authorIdent.getWhenAsInstant(), authorIdent.getZoneId()))); PersonIdent committerIdent = latestCommit.getCommitterIdent(); - commit.put("committer", Map.of("time", formatDate(committerIdent.getWhen(), committerIdent.getTimeZone()))); + commit.put("committer", + Map.of("time", formatDate(committerIdent.getWhenAsInstant(), committerIdent.getZoneId()))); Map user = new LinkedHashMap<>(); user.put("email", authorIdent.getEmailAddress()); @@ -111,7 +112,9 @@ void gitInfo(InfoBuildTimeConfig config, commit.put("id", id); - data.put("tags", getTags(repository, latestCommit)); + data.put("remote", + GitUtil.sanitizeRemoteUrl(git.getRepository().getConfig().getString("remote", "origin", "url"))); + data.put("tags", getTags(git, latestCommit)); } data.put("commit", commit); @@ -130,9 +133,8 @@ void gitInfo(InfoBuildTimeConfig config, } } - private String formatDate(Date date, TimeZone timeZone) { - return ISO_OFFSET_DATE_TIME.format( - OffsetDateTime.ofInstant(date.toInstant(), timeZone.toZoneId())); + private String formatDate(Instant instant, ZoneId zoneId) { + return ISO_OFFSET_DATE_TIME.format(OffsetDateTime.ofInstant(instant, zoneId)); } private Map obtainBuildInfo(CurateOutcomeBuildItem curateOutcomeBuildItem, @@ -160,13 +162,11 @@ private Map obtainBuildInfo(CurateOutcomeBuildItem curateOutcome return build; } - public Collection getTags(Repository repo, final ObjectId objectId) throws GitAPIException { - try (Git git = Git.wrap(repo)) { - try (RevWalk walk = new RevWalk(repo)) { - Collection tags = getTags(git, objectId, walk); - walk.dispose(); - return tags; - } + public Collection getTags(final Git git, final ObjectId objectId) throws GitAPIException { + try (RevWalk walk = new RevWalk(git.getRepository())) { + Collection tags = getTags(git, objectId, walk); + walk.dispose(); + return tags; } } @@ -211,10 +211,13 @@ void buildInfo(CurateOutcomeBuildItem curateOutcomeBuildItem, InfoBuildTimeConfig config, BuildProducer valuesProducer, BuildProducer beanProducer, + ApplicationInfoBuildItem infoApplication, InfoRecorder recorder) { ApplicationModel applicationModel = curateOutcomeBuildItem.getApplicationModel(); ResolvedDependency appArtifact = applicationModel.getAppArtifact(); Map buildData = new LinkedHashMap<>(); + String name = infoApplication.getName(); + buildData.put("name", name); String group = appArtifact.getGroupId(); buildData.put("group", group); String artifact = appArtifact.getArtifactId(); diff --git a/extensions/info/deployment/src/main/resources/dev-ui/qwc-info.js b/extensions/info/deployment/src/main/resources/dev-ui/qwc-info.js index a89b011d4f67b..2590edb796c58 100644 --- a/extensions/info/deployment/src/main/resources/dev-ui/qwc-info.js +++ b/extensions/info/deployment/src/main/resources/dev-ui/qwc-info.js @@ -104,6 +104,8 @@ export class QwcInfo extends LitElement {

+ +
Version${java.version}
Vendor${java.vendor}
Vendor Version${java.vendorVersion}
`; @@ -151,7 +153,8 @@ export class QwcInfo extends LitElement { _renderOptionalData(git){ if(typeof git.commit.id !== "string"){ return html`Commit User${git.commit.user.name} <${git.commit.user.email}> - Commit Message${unsafeHTML(this._replaceNewLine(git.commit.id.message.full))}` + Commit Message${unsafeHTML(this._replaceNewLine(git.commit.id.message.full))} + Remote URL${unsafeHTML(git.remote)}` } } @@ -165,6 +168,8 @@ export class QwcInfo extends LitElement { return html`
+ + diff --git a/extensions/info/deployment/src/test/java/io/quarkus/info/deployment/EnabledInfoTest.java b/extensions/info/deployment/src/test/java/io/quarkus/info/deployment/EnabledInfoTest.java index fbac5fd326911..af7ca758626b2 100644 --- a/extensions/info/deployment/src/test/java/io/quarkus/info/deployment/EnabledInfoTest.java +++ b/extensions/info/deployment/src/test/java/io/quarkus/info/deployment/EnabledInfoTest.java @@ -70,5 +70,7 @@ public void test() { assertNotNull(javaInfo); assertNotNull(javaInfo.version()); + assertNotNull(javaInfo.vendor()); + assertNotNull(javaInfo.vendorVersion()); } } diff --git a/extensions/info/deployment/src/test/java/io/quarkus/info/deployment/GitUtilTest.java b/extensions/info/deployment/src/test/java/io/quarkus/info/deployment/GitUtilTest.java new file mode 100644 index 0000000000000..bdab391a44ce8 --- /dev/null +++ b/extensions/info/deployment/src/test/java/io/quarkus/info/deployment/GitUtilTest.java @@ -0,0 +1,26 @@ +package io.quarkus.info.deployment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +public class GitUtilTest { + + @Test + public void testSanitizeRemoteUrl() { + assertNull(GitUtil.sanitizeRemoteUrl(null)); + assertNull(GitUtil.sanitizeRemoteUrl("")); + assertNull(GitUtil.sanitizeRemoteUrl(" ")); + assertEquals("github.com:gsmet/quarkusio.github.io.git", + GitUtil.sanitizeRemoteUrl("git@github.com:gsmet/quarkusio.github.io.git")); + assertEquals("github.com:gsmet/quarkusio.github.io.git", + GitUtil.sanitizeRemoteUrl(" git@github.com:gsmet/quarkusio.github.io.git ")); + assertEquals("https://github.com/gsmet/quarkusio.github.io.git", + GitUtil.sanitizeRemoteUrl("https://github.com/gsmet/quarkusio.github.io.git")); + assertEquals("https://github.com/gsmet/quarkusio.github.io.git", + GitUtil.sanitizeRemoteUrl("https://gsmet:password@github.com/gsmet/quarkusio.github.io.git")); + assertEquals("http://github.com/gsmet/quarkusio.github.io.git", + GitUtil.sanitizeRemoteUrl("http://gsmet:password@github.com/gsmet/quarkusio.github.io.git")); + } +} diff --git a/extensions/info/runtime/src/main/java/io/quarkus/info/JavaInfo.java b/extensions/info/runtime/src/main/java/io/quarkus/info/JavaInfo.java index e7e4ed3c7fa90..35a76cf26c9ad 100644 --- a/extensions/info/runtime/src/main/java/io/quarkus/info/JavaInfo.java +++ b/extensions/info/runtime/src/main/java/io/quarkus/info/JavaInfo.java @@ -1,6 +1,31 @@ package io.quarkus.info; +/** + * This interface provides information about the Java runtime. + * + * @see io.quarkus.info.runtime.InfoRecorder + * @see io.quarkus.info.runtime.JavaInfoContributor + */ public interface JavaInfo { + /** + * Return the Java runtime version. + * + * @return string that represent the Java version + */ String version(); + + /** + * Return the Java vendor. + * + * @return string that represent the Java vendor + */ + String vendor(); + + /** + * Return the Java vendor runtime version. + * + * @return string that represent the Java vendor version + */ + String vendorVersion(); } diff --git a/extensions/info/runtime/src/main/java/io/quarkus/info/runtime/InfoRecorder.java b/extensions/info/runtime/src/main/java/io/quarkus/info/runtime/InfoRecorder.java index c3380f685abca..0411426bf63ee 100644 --- a/extensions/info/runtime/src/main/java/io/quarkus/info/runtime/InfoRecorder.java +++ b/extensions/info/runtime/src/main/java/io/quarkus/info/runtime/InfoRecorder.java @@ -134,6 +134,16 @@ public JavaInfo get() { public String version() { return JavaInfoContributor.getVersion(); } + + @Override + public String vendor() { + return JavaInfoContributor.getVendor(); + } + + @Override + public String vendorVersion() { + return JavaInfoContributor.getVendorVersion(); + } }; } }; diff --git a/extensions/info/runtime/src/main/java/io/quarkus/info/runtime/JavaInfoContributor.java b/extensions/info/runtime/src/main/java/io/quarkus/info/runtime/JavaInfoContributor.java index 11a88a612afbd..e381b3ba41b6e 100644 --- a/extensions/info/runtime/src/main/java/io/quarkus/info/runtime/JavaInfoContributor.java +++ b/extensions/info/runtime/src/main/java/io/quarkus/info/runtime/JavaInfoContributor.java @@ -17,10 +17,20 @@ public Map data() { //TODO: should we add more information like 'java.runtime.*' and 'java.vm.*' ? Map result = new LinkedHashMap<>(); result.put("version", getVersion()); + result.put("vendor", getVendor()); + result.put("vendorVersion", getVendorVersion()); return result; } static String getVersion() { return System.getProperty("java.version"); } + + static String getVendor() { + return System.getProperty("java.vendor"); + } + + static String getVendorVersion() { + return System.getProperty("java.vendor.version"); + } } diff --git a/extensions/jaxb/deployment/pom.xml b/extensions/jaxb/deployment/pom.xml index 15589c3c49e5b..53d532adc436b 100644 --- a/extensions/jaxb/deployment/pom.xml +++ b/extensions/jaxb/deployment/pom.xml @@ -56,9 +56,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbProcessor.java b/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbProcessor.java index b08e753e7b3fa..aaf0c32bc958f 100644 --- a/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbProcessor.java +++ b/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbProcessor.java @@ -342,8 +342,8 @@ FilteredJaxbClassesToBeBoundBuildItem filterBoundClasses( .forEach(builder::classNames); // remove classes that have been excluded by users - if (config.excludeClasses.isPresent()) { - builder.classNameExcludes(config.excludeClasses.get()); + if (config.excludeClasses().isPresent()) { + builder.classNameExcludes(config.excludeClasses().get()); } return builder.build(); } @@ -362,7 +362,7 @@ void bindClassesToJaxbContext( .resolveBeans(Type.create(DotName.createSimple(JAXBContext.class), org.jboss.jandex.Type.Kind.CLASS)); if (!beans.isEmpty()) { jaxbContextConfig.addClassesToBeBound(filteredClassesToBeBound.getClasses()); - if (config.validateJaxbContext) { + if (config.validateJaxbContext()) { validateJaxbContext(filteredClassesToBeBound, beanResolver, beans); } } diff --git a/extensions/jaxb/runtime/pom.xml b/extensions/jaxb/runtime/pom.xml index 3c6144fcbb637..17ad6ef0aede2 100644 --- a/extensions/jaxb/runtime/pom.xml +++ b/extensions/jaxb/runtime/pom.xml @@ -71,9 +71,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/extensions/jaxb/runtime/src/main/java/io/quarkus/jaxb/runtime/JaxbConfig.java b/extensions/jaxb/runtime/src/main/java/io/quarkus/jaxb/runtime/JaxbConfig.java index 10aee70162135..dc456f329460b 100644 --- a/extensions/jaxb/runtime/src/main/java/io/quarkus/jaxb/runtime/JaxbConfig.java +++ b/extensions/jaxb/runtime/src/main/java/io/quarkus/jaxb/runtime/JaxbConfig.java @@ -3,24 +3,25 @@ import java.util.List; import java.util.Optional; -import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; -@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED, name = "jaxb") -public class JaxbConfig { +@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +@ConfigMapping(prefix = "quarkus.jaxb") +public interface JaxbConfig { /** * If enabled, it will validate the default JAXB context at build time. */ - @ConfigItem(defaultValue = "false") - public boolean validateJaxbContext; + @WithDefault("false") + boolean validateJaxbContext(); /** * Exclude classes to automatically be bound to the default JAXB context. * Values with suffix {@code .*}, i.e. {@code org.acme.*}, are considered packages and exclude all classes that are members * of these packages */ - @ConfigItem - public Optional> excludeClasses; + Optional> excludeClasses(); } diff --git a/extensions/jdbc/jdbc-mariadb/runtime/src/main/java/io/quarkus/jdbc/mariadb/runtime/graal/SendPamAuthPacketFactory_Substitutions.java b/extensions/jdbc/jdbc-mariadb/runtime/src/main/java/io/quarkus/jdbc/mariadb/runtime/graal/SendPamAuthPacketFactory_Substitutions.java deleted file mode 100644 index d775c42f26c41..0000000000000 --- a/extensions/jdbc/jdbc-mariadb/runtime/src/main/java/io/quarkus/jdbc/mariadb/runtime/graal/SendPamAuthPacketFactory_Substitutions.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.quarkus.jdbc.mariadb.runtime.graal; - -import org.mariadb.jdbc.Configuration; -import org.mariadb.jdbc.HostAddress; -import org.mariadb.jdbc.plugin.AuthenticationPlugin; -import org.mariadb.jdbc.plugin.authentication.standard.SendPamAuthPacketFactory; - -import com.oracle.svm.core.annotate.Substitute; -import com.oracle.svm.core.annotate.TargetClass; - -/** - * The SendPamAuthPacketFactory class is not supported in native mode. - */ -@TargetClass(SendPamAuthPacketFactory.class) -public final class SendPamAuthPacketFactory_Substitutions { - - @Substitute - public AuthenticationPlugin initialize(String authenticationData, byte[] seed, Configuration conf, - HostAddress hostAddress) { - throw new UnsupportedOperationException("Authentication strategy 'dialog' is not supported in GraalVM"); - } - -} diff --git a/extensions/jdbc/jdbc-mariadb/runtime/src/main/java/io/quarkus/jdbc/mariadb/runtime/graal/SendPamAuthPacket_Substitutions.java b/extensions/jdbc/jdbc-mariadb/runtime/src/main/java/io/quarkus/jdbc/mariadb/runtime/graal/SendPamAuthPacket_Substitutions.java index 8365c7b36f3bf..b03ac4cb81651 100644 --- a/extensions/jdbc/jdbc-mariadb/runtime/src/main/java/io/quarkus/jdbc/mariadb/runtime/graal/SendPamAuthPacket_Substitutions.java +++ b/extensions/jdbc/jdbc-mariadb/runtime/src/main/java/io/quarkus/jdbc/mariadb/runtime/graal/SendPamAuthPacket_Substitutions.java @@ -1,15 +1,29 @@ package io.quarkus.jdbc.mariadb.runtime.graal; -import org.mariadb.jdbc.plugin.authentication.standard.SendPamAuthPacket; +import java.io.IOException; +import java.sql.SQLException; -import com.oracle.svm.core.annotate.Delete; +import org.mariadb.jdbc.Configuration; +import org.mariadb.jdbc.HostAddress; +import org.mariadb.jdbc.client.Context; +import org.mariadb.jdbc.client.ReadableByteBuf; +import org.mariadb.jdbc.client.socket.Reader; +import org.mariadb.jdbc.client.socket.Writer; + +import com.oracle.svm.core.annotate.Substitute; import com.oracle.svm.core.annotate.TargetClass; -/** - * The SendPamAuthPacket class is not supported in native mode. - */ -@Delete -@TargetClass(SendPamAuthPacket.class) +@TargetClass(className = "org.mariadb.jdbc.plugin.authentication.standard.SendPamAuthPacket") public final class SendPamAuthPacket_Substitutions { + @Substitute + public void initialize(String authenticationData, byte[] seed, Configuration conf, HostAddress hostAddress) { + throw new UnsupportedOperationException("Authentication strategy 'dialog' is not supported in GraalVM"); + } + + @Substitute + public ReadableByteBuf process(Writer out, Reader in, Context context) + throws SQLException, IOException { + throw new UnsupportedOperationException("Authentication strategy 'dialog' is not supported in GraalVM"); + } } diff --git a/extensions/jfr/deployment/src/main/java/io/quarkus/jfr/deployment/JfrProcessor.java b/extensions/jfr/deployment/src/main/java/io/quarkus/jfr/deployment/JfrProcessor.java index d5494245b1874..caad3d89959e1 100644 --- a/extensions/jfr/deployment/src/main/java/io/quarkus/jfr/deployment/JfrProcessor.java +++ b/extensions/jfr/deployment/src/main/java/io/quarkus/jfr/deployment/JfrProcessor.java @@ -12,11 +12,13 @@ import io.quarkus.jfr.runtime.OTelIdProducer; import io.quarkus.jfr.runtime.QuarkusIdProducer; import io.quarkus.jfr.runtime.config.JfrRuntimeConfig; -import io.quarkus.jfr.runtime.http.rest.ClassicServerRecorderProducer; -import io.quarkus.jfr.runtime.http.rest.JfrClassicServerFilter; -import io.quarkus.jfr.runtime.http.rest.JfrReactiveServerFilter; -import io.quarkus.jfr.runtime.http.rest.ReactiveServerRecorderProducer; +import io.quarkus.jfr.runtime.http.rest.classic.ClassicServerFilter; +import io.quarkus.jfr.runtime.http.rest.classic.ClassicServerRecorderProducer; +import io.quarkus.jfr.runtime.http.rest.reactive.ReactiveServerFilters; +import io.quarkus.jfr.runtime.http.rest.reactive.ReactiveServerRecorderProducer; +import io.quarkus.jfr.runtime.http.rest.reactive.ServerStartRecordingHandler; import io.quarkus.resteasy.common.spi.ResteasyJaxrsProviderBuildItem; +import io.quarkus.resteasy.reactive.server.spi.GlobalHandlerCustomizerBuildItem; import io.quarkus.resteasy.reactive.spi.CustomContainerRequestFilterBuildItem; @BuildSteps @@ -52,7 +54,8 @@ void registerRequestIdProducer(Capabilities capabilities, @BuildStep void registerRestIntegration(Capabilities capabilities, BuildProducer filterBeans, - BuildProducer additionalBeans) { + BuildProducer additionalBeans, + BuildProducer globalHandlerCustomizerProducer) { if (capabilities.isPresent(Capability.RESTEASY_REACTIVE)) { @@ -61,7 +64,10 @@ void registerRestIntegration(Capabilities capabilities, .build()); filterBeans - .produce(new CustomContainerRequestFilterBuildItem(JfrReactiveServerFilter.class.getName())); + .produce(new CustomContainerRequestFilterBuildItem(ReactiveServerFilters.class.getName())); + + globalHandlerCustomizerProducer + .produce(new GlobalHandlerCustomizerBuildItem(new ServerStartRecordingHandler.Customizer())); } } @@ -76,7 +82,7 @@ void registerResteasyClassicIntegration(Capabilities capabilities, .build()); resteasyJaxrsProviderBuildItemBuildProducer - .produce(new ResteasyJaxrsProviderBuildItem(JfrClassicServerFilter.class.getName())); + .produce(new ResteasyJaxrsProviderBuildItem(ClassicServerFilter.class.getName())); } } diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/JfrReactiveServerFilter.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/JfrReactiveServerFilter.java deleted file mode 100644 index 46f14bdead66e..0000000000000 --- a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/JfrReactiveServerFilter.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.quarkus.jfr.runtime.http.rest; - -import jakarta.inject.Inject; -import jakarta.ws.rs.container.ContainerResponseContext; -import jakarta.ws.rs.core.Response; - -import org.jboss.logging.Logger; -import org.jboss.resteasy.reactive.server.ServerRequestFilter; -import org.jboss.resteasy.reactive.server.ServerResponseFilter; - -public class JfrReactiveServerFilter { - - private static final Logger LOG = Logger.getLogger(JfrReactiveServerFilter.class); - - @Inject - Recorder recorder; - - @ServerRequestFilter - public void requestFilter() { - if (LOG.isDebugEnabled()) { - LOG.debug("Enter Jfr Reactive Request Filter"); - } - recorder.recordStartEvent(); - recorder.startPeriodEvent(); - } - - @ServerResponseFilter - public void responseFilter(ContainerResponseContext responseContext) { - if (LOG.isDebugEnabled()) { - LOG.debug("Enter Jfr Reactive Response Filter"); - } - if (isRecordable(responseContext)) { - recorder.endPeriodEvent(); - recorder.recordEndEvent(); - } else { - if (LOG.isDebugEnabled()) { - LOG.debug("Recording REST event was skipped"); - } - } - } - - private boolean isRecordable(ContainerResponseContext responseContext) { - return responseContext.getStatus() != Response.Status.NOT_FOUND.getStatusCode(); - } -} diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/ReactiveServerRecorderProducer.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/ReactiveServerRecorderProducer.java deleted file mode 100644 index 393e50c84848e..0000000000000 --- a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/ReactiveServerRecorderProducer.java +++ /dev/null @@ -1,38 +0,0 @@ -package io.quarkus.jfr.runtime.http.rest; - -import jakarta.enterprise.context.Dependent; -import jakarta.enterprise.context.RequestScoped; -import jakarta.enterprise.inject.Produces; -import jakarta.inject.Inject; -import jakarta.ws.rs.core.Context; - -import org.jboss.resteasy.reactive.server.SimpleResourceInfo; - -import io.quarkus.jfr.runtime.IdProducer; -import io.vertx.core.http.HttpServerRequest; - -@Dependent -public class ReactiveServerRecorderProducer { - - @Context - HttpServerRequest vertxRequest; - - @Context - SimpleResourceInfo resourceInfo; - - @Inject - IdProducer idProducer; - - @Produces - @RequestScoped - public Recorder create() { - String httpMethod = vertxRequest.method().name(); - String uri = vertxRequest.path(); - Class resourceClass = resourceInfo.getResourceClass(); - String resourceClassName = (resourceClass == null) ? null : resourceClass.getName(); - String resourceMethodName = resourceInfo.getMethodName(); - String client = vertxRequest.remoteAddress().toString(); - - return new ServerRecorder(httpMethod, uri, resourceClassName, resourceMethodName, client, idProducer); - } -} diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/Recorder.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/Recorder.java deleted file mode 100644 index fc8535b773d67..0000000000000 --- a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/Recorder.java +++ /dev/null @@ -1,12 +0,0 @@ -package io.quarkus.jfr.runtime.http.rest; - -public interface Recorder { - - void recordStartEvent(); - - void recordEndEvent(); - - void startPeriodEvent(); - - void endPeriodEvent(); -} diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/JfrClassicServerFilter.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/classic/ClassicServerFilter.java similarity index 76% rename from extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/JfrClassicServerFilter.java rename to extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/classic/ClassicServerFilter.java index e019c5dfb6710..2e67e40201571 100644 --- a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/JfrClassicServerFilter.java +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/classic/ClassicServerFilter.java @@ -1,4 +1,4 @@ -package io.quarkus.jfr.runtime.http.rest; +package io.quarkus.jfr.runtime.http.rest.classic; import java.io.IOException; @@ -14,16 +14,16 @@ import io.quarkus.arc.Arc; @Provider -public class JfrClassicServerFilter implements ContainerRequestFilter, ContainerResponseFilter { +public class ClassicServerFilter implements ContainerRequestFilter, ContainerResponseFilter { - private static final Logger LOG = Logger.getLogger(JfrClassicServerFilter.class); + private static final Logger LOG = Logger.getLogger(ClassicServerFilter.class); @Override public void filter(ContainerRequestContext requestContext) throws IOException { if (LOG.isDebugEnabled()) { LOG.debug("Enter Jfr Classic Request Filter"); } - Recorder recorder = Arc.container().instance(Recorder.class).get(); + ClassicServerRecorder recorder = Arc.container().instance(ClassicServerRecorder.class).get(); recorder.recordStartEvent(); recorder.startPeriodEvent(); } @@ -36,7 +36,7 @@ public void filter(ContainerRequestContext requestContext, ContainerResponseCont } if (isRecordable(responseContext)) { - Recorder recorder = Arc.container().instance(Recorder.class).get(); + ClassicServerRecorder recorder = Arc.container().instance(ClassicServerRecorder.class).get(); recorder.endPeriodEvent(); recorder.recordEndEvent(); } else { diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/ServerRecorder.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/classic/ClassicServerRecorder.java similarity index 81% rename from extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/ServerRecorder.java rename to extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/classic/ClassicServerRecorder.java index 3f2dc0428fdc6..fed42a45de64e 100644 --- a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/ServerRecorder.java +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/classic/ClassicServerRecorder.java @@ -1,9 +1,12 @@ -package io.quarkus.jfr.runtime.http.rest; +package io.quarkus.jfr.runtime.http.rest.classic; import io.quarkus.jfr.runtime.IdProducer; import io.quarkus.jfr.runtime.http.AbstractHttpEvent; +import io.quarkus.jfr.runtime.http.rest.RestEndEvent; +import io.quarkus.jfr.runtime.http.rest.RestPeriodEvent; +import io.quarkus.jfr.runtime.http.rest.RestStartEvent; -public class ServerRecorder implements Recorder { +public class ClassicServerRecorder { private final String httpMethod; private final String uri; @@ -13,7 +16,8 @@ public class ServerRecorder implements Recorder { private final IdProducer idProducer; private RestPeriodEvent durationEvent; - public ServerRecorder(String httpMethod, String uri, String resourceClass, String resourceMethod, String client, + public ClassicServerRecorder(String httpMethod, String uri, String resourceClass, String resourceMethod, + String client, IdProducer idProducer) { this.httpMethod = httpMethod; this.uri = uri; @@ -23,7 +27,6 @@ public ServerRecorder(String httpMethod, String uri, String resourceClass, Strin this.idProducer = idProducer; } - @Override public void recordStartEvent() { RestStartEvent startEvent = new RestStartEvent(); @@ -34,7 +37,6 @@ public void recordStartEvent() { } } - @Override public void recordEndEvent() { RestEndEvent endEvent = new RestEndEvent(); @@ -45,13 +47,11 @@ public void recordEndEvent() { } } - @Override public void startPeriodEvent() { durationEvent = new RestPeriodEvent(); durationEvent.begin(); } - @Override public void endPeriodEvent() { durationEvent.end(); diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/ClassicServerRecorderProducer.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/classic/ClassicServerRecorderProducer.java similarity index 83% rename from extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/ClassicServerRecorderProducer.java rename to extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/classic/ClassicServerRecorderProducer.java index 9ee161e302ade..355df43b5571e 100644 --- a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/ClassicServerRecorderProducer.java +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/classic/ClassicServerRecorderProducer.java @@ -1,4 +1,4 @@ -package io.quarkus.jfr.runtime.http.rest; +package io.quarkus.jfr.runtime.http.rest.classic; import java.lang.reflect.Method; @@ -25,7 +25,7 @@ public class ClassicServerRecorderProducer { @Produces @RequestScoped - public Recorder create() { + public ClassicServerRecorder create() { String httpMethod = vertxRequest.method().name(); String uri = vertxRequest.path(); Class resourceClass = resourceInfo.getResourceClass(); @@ -34,6 +34,6 @@ public Recorder create() { String resourceMethodName = (resourceMethod == null) ? null : resourceMethod.getName(); String client = vertxRequest.remoteAddress().toString(); - return new ServerRecorder(httpMethod, uri, resourceClassName, resourceMethodName, client, idProducer); + return new ClassicServerRecorder(httpMethod, uri, resourceClassName, resourceMethodName, client, idProducer); } } diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ReactiveServerFilters.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ReactiveServerFilters.java new file mode 100644 index 0000000000000..b7d1566dfb9be --- /dev/null +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ReactiveServerFilters.java @@ -0,0 +1,50 @@ +package io.quarkus.jfr.runtime.http.rest.reactive; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.server.ServerRequestFilter; +import org.jboss.resteasy.reactive.server.ServerResponseFilter; +import org.jboss.resteasy.reactive.server.SimpleResourceInfo; + +public class ReactiveServerFilters { + + private static final Logger LOG = Logger.getLogger(ReactiveServerFilters.class); + + private final ReactiveServerRecorder recorder; + + public ReactiveServerFilters(ReactiveServerRecorder recorder) { + this.recorder = recorder; + } + + /** + * Executed if request processing proceeded correctly. + * We now have to update the start event with the resource class and method data and also commit the event. + */ + @ServerRequestFilter + public void requestFilter(SimpleResourceInfo resourceInfo) { + Class resourceClass = resourceInfo.getResourceClass(); + if (resourceClass != null) { // should always be the case + String resourceClassName = resourceClass.getName(); + String resourceMethodName = resourceInfo.getMethodName(); + recorder + .updateResourceInfo(new ResourceInfo(resourceClassName, resourceMethodName)) + .commitStartEventIfNecessary(); + } + + } + + /** + * This will execute regardless of a processing failure or not. + * If there was a failure, we need to check if the start event was not commited + * (which happens when request was not matched to any resource method) and if so, commit it. + */ + @ServerResponseFilter + public void responseFilter() { + if (LOG.isDebugEnabled()) { + LOG.debug("Enter Jfr Reactive Response Filter"); + } + recorder + .recordEndEvent() + .endPeriodEvent(); + } + +} diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ReactiveServerRecorder.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ReactiveServerRecorder.java new file mode 100644 index 0000000000000..151736f520d0d --- /dev/null +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ReactiveServerRecorder.java @@ -0,0 +1,98 @@ +package io.quarkus.jfr.runtime.http.rest.reactive; + +import io.quarkus.jfr.runtime.IdProducer; +import io.quarkus.jfr.runtime.http.AbstractHttpEvent; +import io.quarkus.jfr.runtime.http.rest.RestEndEvent; +import io.quarkus.jfr.runtime.http.rest.RestPeriodEvent; +import io.quarkus.jfr.runtime.http.rest.RestStartEvent; + +class ReactiveServerRecorder { + + private final RequestInfo requestInfo; + private final IdProducer idProducer; + + private volatile ResourceInfo resourceInfo; + + private volatile RestStartEvent startEvent; + // TODO: we can perhaps get rid of this volatile if access patterns to this and startEvent allow it + private volatile boolean startEventHandled; + + private volatile RestPeriodEvent durationEvent; + + public ReactiveServerRecorder(RequestInfo requestInfo, IdProducer idProducer) { + this.requestInfo = requestInfo; + this.idProducer = idProducer; + } + + public ReactiveServerRecorder createStartEvent() { + startEvent = new RestStartEvent(); + return this; + } + + public ReactiveServerRecorder createAndStartPeriodEvent() { + durationEvent = new RestPeriodEvent(); + durationEvent.begin(); + return this; + } + + public ReactiveServerRecorder updateResourceInfo(ResourceInfo resourceInfo) { + this.resourceInfo = resourceInfo; + return this; + } + + public ReactiveServerRecorder commitStartEventIfNecessary() { + startEventHandled = true; + var se = startEvent; + if (se.shouldCommit()) { + setHttpInfo(startEvent); + se.commit(); + } + return this; + } + + /** + * Because this can be called when a start event has not been completely handled + * (this happens when request processing failed because a Resource method could not be identified), + * we need to handle that event as well. + */ + public ReactiveServerRecorder recordEndEvent() { + if (!startEventHandled) { + commitStartEventIfNecessary(); + } + + RestEndEvent endEvent = new RestEndEvent(); + if (endEvent.shouldCommit()) { + setHttpInfo(endEvent); + endEvent.commit(); + } + + return this; + } + + public ReactiveServerRecorder endPeriodEvent() { + if (durationEvent != null) { + durationEvent.end(); + if (durationEvent.shouldCommit()) { + setHttpInfo(durationEvent); + durationEvent.commit(); + } + } else { + // this shouldn't happen, but if it does due to an error on our side, the request processing shouldn't be botched because of it + } + + return this; + } + + private void setHttpInfo(AbstractHttpEvent event) { + event.setTraceId(idProducer.getTraceId()); + event.setSpanId(idProducer.getSpanId()); + event.setHttpMethod(requestInfo.httpMethod()); + event.setUri(requestInfo.uri()); + event.setClient(requestInfo.remoteAddress()); + var ri = resourceInfo; + if (resourceInfo != null) { + event.setResourceClass(ri.resourceClass()); + event.setResourceMethod(ri.resourceMethod()); + } + } +} diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ReactiveServerRecorderProducer.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ReactiveServerRecorderProducer.java new file mode 100644 index 0000000000000..62cd81ce11bc5 --- /dev/null +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ReactiveServerRecorderProducer.java @@ -0,0 +1,18 @@ +package io.quarkus.jfr.runtime.http.rest.reactive; + +import jakarta.enterprise.context.RequestScoped; + +import io.quarkus.jfr.runtime.IdProducer; +import io.vertx.core.http.HttpServerRequest; + +public class ReactiveServerRecorderProducer { + + @RequestScoped + public ReactiveServerRecorder create(IdProducer idProducer, HttpServerRequest vertxRequest) { + String httpMethod = vertxRequest.method().name(); + String uri = vertxRequest.path(); + String client = vertxRequest.remoteAddress().toString(); + + return new ReactiveServerRecorder(new RequestInfo(httpMethod, uri, client), idProducer); + } +} diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/RequestInfo.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/RequestInfo.java new file mode 100644 index 0000000000000..41a9baa19add4 --- /dev/null +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/RequestInfo.java @@ -0,0 +1,4 @@ +package io.quarkus.jfr.runtime.http.rest.reactive; + +record RequestInfo(String httpMethod, String uri, String remoteAddress) { +} diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ResourceInfo.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ResourceInfo.java new file mode 100644 index 0000000000000..219b842e679c7 --- /dev/null +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ResourceInfo.java @@ -0,0 +1,4 @@ +package io.quarkus.jfr.runtime.http.rest.reactive; + +record ResourceInfo(String resourceClass, String resourceMethod) { +} diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ServerStartRecordingHandler.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ServerStartRecordingHandler.java new file mode 100644 index 0000000000000..74ec6fd7f61c0 --- /dev/null +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/http/rest/reactive/ServerStartRecordingHandler.java @@ -0,0 +1,48 @@ +package io.quarkus.jfr.runtime.http.rest.reactive; + +import java.util.Collections; +import java.util.List; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.common.model.ResourceClass; +import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; +import org.jboss.resteasy.reactive.server.model.HandlerChainCustomizer; +import org.jboss.resteasy.reactive.server.model.ServerResourceMethod; +import org.jboss.resteasy.reactive.server.spi.ServerRestHandler; + +import io.quarkus.arc.Arc; +import io.quarkus.jfr.runtime.http.rest.RestStartEvent; + +/** + * Kicks off the creation of a {@link RestStartEvent}. + * This is done very early as to be able to capture events such as 405, 406, etc. + */ +public class ServerStartRecordingHandler implements ServerRestHandler { + + private static final ServerStartRecordingHandler INSTANCE = new ServerStartRecordingHandler(); + + private static final Logger LOG = Logger.getLogger(ServerStartRecordingHandler.class); + + @Override + public void handle(ResteasyReactiveRequestContext requestContext) { + if (LOG.isDebugEnabled()) { + LOG.debug("Enter Jfr Reactive Request Filter"); + } + requestContext.requireCDIRequestScope(); + ReactiveServerRecorder recorder = Arc.container().instance(ReactiveServerRecorder.class).get(); + recorder + .createStartEvent() + .createAndStartPeriodEvent(); + } + + public static class Customizer implements HandlerChainCustomizer { + @Override + public List handlers(Phase phase, ResourceClass resourceClass, + ServerResourceMethod serverResourceMethod) { + if (phase == Phase.AFTER_PRE_MATCH) { + return Collections.singletonList(INSTANCE); + } + return Collections.emptyList(); + } + } +} diff --git a/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/DevServicesKafkaProcessor.java b/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/DevServicesKafkaProcessor.java index 53a0f45a83e39..f6a3c35aa8f3d 100644 --- a/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/DevServicesKafkaProcessor.java +++ b/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/DevServicesKafkaProcessor.java @@ -228,7 +228,7 @@ private RunningDevService startKafka(DockerStatusBuildItem dockerStatusBuildItem switch (config.provider) { case REDPANDA: RedpandaKafkaContainer redpanda = new RedpandaKafkaContainer( - DockerImageName.parse(config.imageName).asCompatibleSubstituteFor("vectorized/redpanda"), + DockerImageName.parse(config.imageName).asCompatibleSubstituteFor("redpandadata/redpanda"), config.fixedExposedPort, launchMode.getLaunchMode() == LaunchMode.DEVELOPMENT ? config.serviceName : null, useSharedNetwork, config.redpanda); diff --git a/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaDevServicesBuildTimeConfig.java b/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaDevServicesBuildTimeConfig.java index d3bbc818fc103..4207faf0f521b 100644 --- a/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaDevServicesBuildTimeConfig.java +++ b/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaDevServicesBuildTimeConfig.java @@ -34,7 +34,7 @@ public class KafkaDevServicesBuildTimeConfig { * Redpanda, Strimzi and kafka-native container providers are supported. Default is redpanda. *

* For Redpanda: - * See https://docs.redpanda.com/current/get-started/quick-start/ and https://hub.docker.com/r/vectorized/redpanda + * See https://docs.redpanda.com/current/get-started/quick-start/ and https://hub.docker.com/r/redpandadata/redpanda *

* For Strimzi: * See https://github.com/strimzi/test-container and https://quay.io/repository/strimzi-test-container/test-container @@ -48,7 +48,7 @@ public class KafkaDevServicesBuildTimeConfig { public Provider provider = Provider.REDPANDA; public enum Provider { - REDPANDA("docker.io/vectorized/redpanda:v24.1.2"), + REDPANDA("docker.io/redpandadata/redpanda:v24.1.2"), STRIMZI("quay.io/strimzi-test-container/test-container:latest-kafka-3.7.0"), KAFKA_NATIVE("quay.io/ogunalp/kafka-native:latest"); diff --git a/extensions/kafka-client/deployment/src/main/resources/dev-ui/qwc-kafka-add-topic.js b/extensions/kafka-client/deployment/src/main/resources/dev-ui/qwc-kafka-add-topic.js index ad8ca2104ee67..c26c0d9cb26ea 100644 --- a/extensions/kafka-client/deployment/src/main/resources/dev-ui/qwc-kafka-add-topic.js +++ b/extensions/kafka-client/deployment/src/main/resources/dev-ui/qwc-kafka-add-topic.js @@ -59,6 +59,13 @@ export class QwcKafkaAddTopic extends LitElement { min="0" max="99"> + + ${this._renderButtons()}`; } @@ -70,10 +77,11 @@ export class QwcKafkaAddTopic extends LitElement { } _reset(){ - this._newTopic = new Object(); + this._newTopic = {}; this._newTopic.name = ''; this._newTopic.partitions = 1; this._newTopic.replications = 1; + this._newTopic.configs = undefined; } _cancel(){ @@ -89,11 +97,11 @@ export class QwcKafkaAddTopic extends LitElement { _submit(){ if(this._newTopic.name.trim() !== ''){ - this.jsonRpc.createTopic({ topicName: this._newTopic.name, partitions: parseInt(this._newTopic.partitions), - replications: parseInt(this._newTopic.replications) + replications: parseInt(this._newTopic.replications), + configs: this._newTopic.configs }).then(jsonRpcResponse => { this._reset(); const success = new CustomEvent("kafka-topic-added-success", { @@ -119,6 +127,17 @@ export class QwcKafkaAddTopic extends LitElement { _replicationsChanged(e){ this._newTopic.replications = e.detail.value; } + + _configsChanged(e){ + this._newTopic.configs = Object.fromEntries(e.detail.value.split(',') + .reduce((configs, item) => { + const split = item.trim().split('='); + if (split.length > 1) { + configs.set(split[0], split[1]); + } + return configs; + }, new Map())); + } } customElements.define('qwc-kafka-add-topic', QwcKafkaAddTopic); \ No newline at end of file diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaAdminClient.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaAdminClient.java index 0ced0504abcda..8f8486630e18c 100644 --- a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaAdminClient.java +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaAdminClient.java @@ -70,23 +70,23 @@ public Collection getConsumerGroups() throws Interrupt .values(); } - public boolean deleteTopic(String name) { + public boolean deleteTopic(final String name) { Collection topics = new ArrayList<>(); topics.add(name); DeleteTopicsResult dtr = client.deleteTopics(topics); return dtr.topicNameValues() != null; } - public boolean createTopic(KafkaCreateTopicRequest kafkaCreateTopicRq) { + public boolean createTopic(final KafkaCreateTopicRequest kafkaCreateTopicRq) { var partitions = Optional.ofNullable(kafkaCreateTopicRq.getPartitions()).orElse(1); var replications = Optional.ofNullable(kafkaCreateTopicRq.getReplications()).orElse((short) 1); var newTopic = new NewTopic(kafkaCreateTopicRq.getTopicName(), partitions, replications); - + newTopic.configs(Optional.ofNullable(kafkaCreateTopicRq.getConfigs()).orElse(Map.of())); CreateTopicsResult ctr = client.createTopics(List.of(newTopic)); return ctr.values() != null; } - public ListConsumerGroupOffsetsResult listConsumerGroupOffsets(String groupId) { + public ListConsumerGroupOffsetsResult listConsumerGroupOffsets(final String groupId) { return client.listConsumerGroupOffsets(groupId); } @@ -96,7 +96,7 @@ public Collection getAclInfo() throws InterruptedException, Executio return client.describeAcls(filter, options).values().get(); } - public Map describeTopics(Collection topicNames) + public Map describeTopics(final Collection topicNames) throws InterruptedException, ExecutionException { return client.describeTopics(topicNames) .allTopicNames() diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/devui/KafkaJsonRPCService.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/devui/KafkaJsonRPCService.java index c9d37cbb001e4..b5fe4ebdd3fcf 100644 --- a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/devui/KafkaJsonRPCService.java +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/devui/KafkaJsonRPCService.java @@ -30,10 +30,12 @@ public List getTopics() throws InterruptedException, ExecutionExcept return kafkaUiUtils.getTopics(); } - public List createTopic(String topicName, int partitions, int replications) + public List createTopic(final String topicName, final int partitions, final int replications, + Map configs) throws InterruptedException, ExecutionException { - KafkaCreateTopicRequest createTopicRequest = new KafkaCreateTopicRequest(topicName, partitions, (short) replications); + KafkaCreateTopicRequest createTopicRequest = new KafkaCreateTopicRequest(topicName, partitions, (short) replications, + configs); boolean created = kafkaAdminClient.createTopic(createTopicRequest); if (created) { return kafkaUiUtils.getTopics(); @@ -41,7 +43,7 @@ public List createTopic(String topicName, int partitions, int replic throw new RuntimeException("Topic [" + topicName + "] not created"); } - public List deleteTopic(String topicName) throws InterruptedException, ExecutionException { + public List deleteTopic(final String topicName) throws InterruptedException, ExecutionException { boolean deleted = kafkaAdminClient.deleteTopic(topicName); if (deleted) { return kafkaUiUtils.getTopics(); @@ -49,7 +51,7 @@ public List deleteTopic(String topicName) throws InterruptedExceptio throw new RuntimeException("Topic [" + topicName + "] not deleted"); } - public KafkaMessagePage topicMessages(String topicName) throws ExecutionException, InterruptedException { + public KafkaMessagePage topicMessages(final String topicName) throws ExecutionException, InterruptedException { List partitions = getPartitions(topicName); KafkaOffsetRequest offsetRequest = new KafkaOffsetRequest(topicName, partitions, Order.NEW_FIRST); Map offset = kafkaUiUtils.getOffset(offsetRequest); @@ -71,7 +73,7 @@ public KafkaMessagePage createMessage(String topicName, Integer partition, Strin return topicMessages(topicName); } - public List getPartitions(String topicName) throws ExecutionException, InterruptedException { + public List getPartitions(final String topicName) throws ExecutionException, InterruptedException { return new ArrayList<>(kafkaUiUtils.partitions(topicName)); } diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/devui/model/request/KafkaCreateTopicRequest.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/devui/model/request/KafkaCreateTopicRequest.java index b099bb06ca3e9..883a6ba1ecaa4 100644 --- a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/devui/model/request/KafkaCreateTopicRequest.java +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/devui/model/request/KafkaCreateTopicRequest.java @@ -1,17 +1,22 @@ package io.quarkus.kafka.client.runtime.devui.model.request; +import java.util.Map; + public class KafkaCreateTopicRequest { private String topicName; private Integer partitions; private Short replications; + private Map configs; public KafkaCreateTopicRequest() { } - public KafkaCreateTopicRequest(String topicName, Integer partitions, Short replications) { + public KafkaCreateTopicRequest(final String topicName, final Integer partitions, final Short replications, + final Map configs) { this.topicName = topicName; this.partitions = partitions; this.replications = replications; + this.configs = configs; } public String getTopicName() { @@ -25,4 +30,9 @@ public Integer getPartitions() { public Short getReplications() { return replications; } + + public Map getConfigs() { + return configs; + } + } diff --git a/extensions/kotlin/deployment/src/main/java/io/quarkus/kotlin/deployment/KotlinProcessor.java b/extensions/kotlin/deployment/src/main/java/io/quarkus/kotlin/deployment/KotlinProcessor.java index aff88dfbd80b2..8a95b61750fe2 100644 --- a/extensions/kotlin/deployment/src/main/java/io/quarkus/kotlin/deployment/KotlinProcessor.java +++ b/extensions/kotlin/deployment/src/main/java/io/quarkus/kotlin/deployment/KotlinProcessor.java @@ -62,7 +62,9 @@ void registerKotlinReflection(final BuildProducer refl reflectiveClass.produce(ReflectiveClassBuildItem.builder("kotlin.KotlinVersion$Companion[]").constructors(false) .build()); reflectiveClass.produce( - ReflectiveClassBuildItem.builder("kotlin.collections.EmptyList", "kotlin.collections.EmptyMap").build()); + ReflectiveClassBuildItem + .builder("kotlin.collections.EmptyList", "kotlin.collections.EmptyMap", "kotlin.collections.EmptySet") + .build()); nativeResourcePatterns.produce(builder().includePatterns( "META-INF/.*.kotlin_module$", diff --git a/extensions/kubernetes/openshift/deployment/src/test/java/io/quarkus/openshift/deployment/config/OpenShiftConfigFallbackTest.java b/extensions/kubernetes/openshift/deployment/src/test/java/io/quarkus/openshift/deployment/config/OpenShiftConfigFallbackTest.java index f67261d858adf..b0c8aa6082a49 100644 --- a/extensions/kubernetes/openshift/deployment/src/test/java/io/quarkus/openshift/deployment/config/OpenShiftConfigFallbackTest.java +++ b/extensions/kubernetes/openshift/deployment/src/test/java/io/quarkus/openshift/deployment/config/OpenShiftConfigFallbackTest.java @@ -25,7 +25,6 @@ public class OpenShiftConfigFallbackTest { static final QuarkusProdModeTest TEST = new QuarkusProdModeTest() .setApplicationName("config") .setApplicationVersion("0.1-SNAPSHOT") - .overrideConfigKey("quarkus.kubernetes.replicas", "10") .overrideConfigKey("quarkus.openshift.version", "999-SNAPSHOT") .overrideConfigKey("quarkus.openshift.labels.app", "openshift") .overrideConfigKey("quarkus.openshift.route.expose", "true") @@ -51,10 +50,6 @@ void configFallback() throws Exception { YamlConfigSource kubernetes = new YamlConfigSource(kubernetesDir.resolve("kubernetes.yml").toUri().toURL()); YamlConfigSource openshift = new YamlConfigSource(kubernetesDir.resolve("openshift.yml").toUri().toURL()); - // spec.replicas is only for Kubernetes and Openshift, no fallback - assertEquals("10", kubernetes.getValue("spec.replicas")); - assertEquals("1", openshift.getValue("spec.replicas")); - // In both, each should retain the value assertEquals("0.1-SNAPSHOT", kubernetes.getValue("spec.template.metadata.labels.\"app.kubernetes.io/version\"")); assertEquals("999-SNAPSHOT", openshift.getValue("spec.template.metadata.labels.\"app.kubernetes.io/version\"")); diff --git a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KubernetesConfigBuilderCustomizer.java b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KubernetesConfigBuilderCustomizer.java index bc7f984e8b74e..1e8f7a2ad0577 100644 --- a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KubernetesConfigBuilderCustomizer.java +++ b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KubernetesConfigBuilderCustomizer.java @@ -2,12 +2,10 @@ import static io.smallrye.config.ConfigMappingInterface.getProperties; import static io.smallrye.config.ConfigMappingLoader.getConfigMapping; -import static io.smallrye.config.ProfileConfigSourceInterceptor.convertProfile; +import static io.smallrye.config.ConfigValue.CONFIG_SOURCE_COMPARATOR; -import java.util.Comparator; import java.util.HashSet; import java.util.Iterator; -import java.util.List; import java.util.OptionalInt; import java.util.Set; import java.util.function.Function; @@ -17,7 +15,6 @@ import io.smallrye.config.ConfigSourceInterceptorFactory; import io.smallrye.config.ConfigValue; import io.smallrye.config.FallbackConfigSourceInterceptor; -import io.smallrye.config.NameIterator; import io.smallrye.config.Priorities; import io.smallrye.config.PropertyName; import io.smallrye.config.RelocateConfigSourceInterceptor; @@ -25,19 +22,21 @@ import io.smallrye.config.SmallRyeConfigBuilderCustomizer; public class KubernetesConfigBuilderCustomizer implements SmallRyeConfigBuilderCustomizer { + private static final Set IGNORE_OPENSHIFT_NAMES = ignoreOpenshiftNames(); + private static final Set IGNORE_KNATIVE_NAMES = ignoreKnativeNames(); + @Override public void configBuilder(final SmallRyeConfigBuilder builder) { - Set ignoreNames = ignoreNames(); - builder.withInterceptorFactories(new ConfigSourceInterceptorFactory() { @Override public ConfigSourceInterceptor getInterceptor(final ConfigSourceInterceptorContext context) { return new Fallbacks(new Function() { @Override public String apply(final String name) { - if (name.startsWith("quarkus.openshift.") && !ignoreNames.contains(new PropertyName(name))) { + if (name.startsWith("quarkus.openshift.") && !IGNORE_OPENSHIFT_NAMES.contains(new PropertyName(name))) { return "quarkus.kubernetes." + name.substring(18); - } else if (name.startsWith("quarkus.knative.") && !ignoreNames.contains(new PropertyName(name))) { + } else if (name.startsWith("quarkus.knative.") + && !IGNORE_KNATIVE_NAMES.contains(new PropertyName(name))) { return "quarkus.kubernetes." + name.substring(16); } return name; @@ -64,7 +63,8 @@ public ConfigSourceInterceptor getInterceptor(final ConfigSourceInterceptorConte return new RelocateConfigSourceInterceptor(new Function() { @Override public String apply(final String name) { - if (name.startsWith("quarkus.kubernetes.") && !ignoreNames.contains(new PropertyName(name))) { + if (name.startsWith("quarkus.kubernetes.") + && !IGNORE_OPENSHIFT_NAMES.contains(new PropertyName(name))) { return "quarkus.openshift." + name.substring(19); } return name; @@ -84,7 +84,7 @@ public ConfigSourceInterceptor getInterceptor(final ConfigSourceInterceptorConte return new RelocateConfigSourceInterceptor(new Function() { @Override public String apply(final String name) { - if (name.startsWith("quarkus.kubernetes.") && !ignoreNames.contains(new PropertyName(name))) { + if (name.startsWith("quarkus.kubernetes.") && !IGNORE_KNATIVE_NAMES.contains(new PropertyName(name))) { return "quarkus.knative." + name.substring(19); } return name; @@ -134,73 +134,71 @@ public ConfigValue getValue(final ConfigSourceInterceptorContext context, final } } - // TODO - This will become public in a new version of SmallRye Config - can be removed later - private static final Comparator CONFIG_SOURCE_COMPARATOR = new Comparator() { - @Override - public int compare(ConfigValue original, ConfigValue candidate) { - int result = Integer.compare(original.getConfigSourceOrdinal(), candidate.getConfigSourceOrdinal()); - if (result != 0) { - return result; - } - result = Integer.compare(original.getConfigSourcePosition(), candidate.getConfigSourcePosition()) * -1; - if (result != 0) { - return result; - } - // If both properties are profiled, prioritize the one with the most specific profile. - if (original.getName().charAt(0) == '%' && candidate.getName().charAt(0) == '%') { - List originalProfiles = convertProfile( - new NameIterator(original.getName()).getNextSegment().substring(1)); - List candidateProfiles = convertProfile( - new NameIterator(candidate.getName()).getNextSegment().substring(1)); - return Integer.compare(originalProfiles.size(), candidateProfiles.size()) * -1; - } - return result; - } - }; - /** - * Collect the properties names that are not shared between kubernetes, openshift and - * knative to ignore when performing the fallback functions. + * Collect the properties names that are not shared between kubernetes and openshift + * to ignore when performing the fallback functions. * * @return a Set of properties names to ignore */ - private static Set ignoreNames() { - Set kubernetes = getProperties(getConfigMapping(KubernetesConfig.class)) - .get(KubernetesConfig.class).get("").keySet(); - Set openshift = getProperties(getConfigMapping(OpenShiftConfig.class)) - .get(OpenShiftConfig.class).get("").keySet(); - Set knative = getProperties(getConfigMapping(KnativeConfig.class)) - .get(KnativeConfig.class).get("").keySet(); + private static Set ignoreOpenshiftNames() { + Set kubernetes = getProperties(getConfigMapping(KubernetesConfig.class)).get(KubernetesConfig.class).get("") + .keySet(); + Set openshift = getProperties(getConfigMapping(OpenShiftConfig.class)).get(OpenShiftConfig.class).get("") + .keySet(); Set ignored = new HashSet<>(); for (String name : kubernetes) { - if (!openshift.contains(name) || !knative.contains(name)) { + if (!openshift.contains(name)) { ignored.add(new PropertyName("quarkus.kubernetes." + name)); ignored.add(new PropertyName("quarkus.openshift." + name)); - ignored.add(new PropertyName("quarkus.knative." + name)); } } + for (String name : openshift) { - if (!kubernetes.contains(name) || !knative.contains(name)) { + if (!kubernetes.contains(name)) { + ignored.add(new PropertyName("quarkus.kubernetes." + name)); + ignored.add(new PropertyName("quarkus.openshift." + name)); + } + } + + // These are shared, but must work independently + ignored.add(new PropertyName("quarkus.kubernetes.deploy")); + ignored.add(new PropertyName("quarkus.openshift.deploy")); + ignored.add(new PropertyName("quarkus.kubernetes.deploy-strategy")); + ignored.add(new PropertyName("quarkus.openshift.deploy-strategy")); + return ignored; + } + + /** + * Collect the properties names that are not shared between kubernetes and knative to + * ignore when performing the fallback functions. + * + * @return a Set of properties names to ignore + */ + private static Set ignoreKnativeNames() { + Set kubernetes = getProperties(getConfigMapping(KubernetesConfig.class)).get(KubernetesConfig.class).get("") + .keySet(); + Set knative = getProperties(getConfigMapping(KnativeConfig.class)).get(KnativeConfig.class).get("").keySet(); + + Set ignored = new HashSet<>(); + for (String name : kubernetes) { + if (!knative.contains(name)) { ignored.add(new PropertyName("quarkus.kubernetes." + name)); ignored.add(new PropertyName("quarkus.openshift." + name)); - ignored.add(new PropertyName("quarkus.knative." + name)); } } + for (String name : knative) { - if (!kubernetes.contains(name) || !openshift.contains(name)) { + if (!kubernetes.contains(name)) { ignored.add(new PropertyName("quarkus.kubernetes." + name)); ignored.add(new PropertyName("quarkus.openshift." + name)); - ignored.add(new PropertyName("quarkus.knative." + name)); } } // These are shared, but must work independently ignored.add(new PropertyName("quarkus.kubernetes.deploy")); - ignored.add(new PropertyName("quarkus.openshift.deploy")); ignored.add(new PropertyName("quarkus.knative.deploy")); ignored.add(new PropertyName("quarkus.kubernetes.deploy-strategy")); - ignored.add(new PropertyName("quarkus.openshift.deploy-strategy")); ignored.add(new PropertyName("quarkus.knative.deploy-strategy")); return ignored; } diff --git a/extensions/kubernetes/vanilla/deployment/src/test/java/io/quarkus/kubernetes/deployment/KubernetesConfigFallbackTest.java b/extensions/kubernetes/vanilla/deployment/src/test/java/io/quarkus/kubernetes/deployment/KubernetesConfigFallbackTest.java index 3e3a42c8e9e2a..35387e5433ef3 100644 --- a/extensions/kubernetes/vanilla/deployment/src/test/java/io/quarkus/kubernetes/deployment/KubernetesConfigFallbackTest.java +++ b/extensions/kubernetes/vanilla/deployment/src/test/java/io/quarkus/kubernetes/deployment/KubernetesConfigFallbackTest.java @@ -2,6 +2,7 @@ import static io.smallrye.config.PropertiesConfigSourceLoader.inClassPath; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Duration; @@ -10,6 +11,7 @@ import org.junit.jupiter.api.Test; import io.quarkus.runtime.configuration.DurationConverter; +import io.smallrye.config.PropertiesConfigSource; import io.smallrye.config.SmallRyeConfig; import io.smallrye.config.SmallRyeConfigBuilder; @@ -46,4 +48,23 @@ void fallback() { assertEquals(knative.labels().get(entry.getKey()), entry.getValue()); } } + + @Test + void sharedOnlyBetweenKubernetesAndOpenshift() { + SmallRyeConfig config = new SmallRyeConfigBuilder() + .addDiscoveredCustomizers() + .withConverter(Duration.class, 100, new DurationConverter()) + .withMappingIgnore("quarkus.**") + .withMapping(KubernetesConfig.class) + .withMapping(OpenShiftConfig.class) + .withMapping(KnativeConfig.class) + .withSources(new PropertiesConfigSource(Map.of("quarkus.kubernetes.init-task-defaults.enabled", "false"), "")) + .build(); + + KubernetesConfig kubernetes = config.getConfigMapping(KubernetesConfig.class); + OpenShiftConfig openShift = config.getConfigMapping(OpenShiftConfig.class); + + assertFalse(kubernetes.initTaskDefaults().enabled()); + assertFalse(openShift.initTaskDefaults().enabled()); + } } diff --git a/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/RestClientUriParameterTest.java b/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/RestClientUriParameterTest.java new file mode 100644 index 0000000000000..c1fe6f1afcee3 --- /dev/null +++ b/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/RestClientUriParameterTest.java @@ -0,0 +1,97 @@ +package io.quarkus.micrometer.deployment.binder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.search.Search; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.quarkus.rest.client.reactive.Url; +import io.quarkus.test.QuarkusUnitTest; + +public class RestClientUriParameterTest { + + final static SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .withApplicationRoot( + jar -> jar.addClasses(Resource.class, Client.class)) + .overrideConfigKey("quarkus.redis.devservices.enabled", "false") + .overrideConfigKey("quarkus.rest-client.\"client\".url", "http://does-not-exist.io"); + + @RestClient + Client client; + + @ConfigProperty(name = "quarkus.http.test-port") + Integer testPort; + + @BeforeAll + static void setRegistry() { + Metrics.addRegistry(registry); + } + + @AfterAll() + static void removeRegistry() { + Metrics.removeRegistry(registry); + } + + @Test + public void testOverride() { + String result = client.getById("http://localhost:" + testPort, "bar"); + assertEquals("bar", result); + + Timer clientTimer = registry.find("http.client.requests").timer(); + assertNotNull(clientTimer); + assertEquals("/example/{id}", clientTimer.getId().getTag("uri")); + } + + private Search getMeter(String name) { + return registry.find(name); + } + + @Path("/example") + @RegisterRestClient(baseUri = "http://dummy") + public interface Client { + + @GET + @Path("/{id}") + String getById(@Url String baseUri, @PathParam("id") String id); + } + + @Path("/example") + public static class Resource { + + @RestClient + Client client; + + @GET + @Path("/{id}") + @Produces(MediaType.TEXT_PLAIN) + public String example() { + return "bar"; + } + + @GET + @Path("/call") + @Produces(MediaType.TEXT_PLAIN) + public String call() { + return client.getById("http://localhost:8080", "1"); + } + } +} diff --git a/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/UriTagWithHttpRootTest.java b/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/UriTagWithHttpRootTest.java index d153fdb65fc5b..8bca89242a0e7 100644 --- a/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/UriTagWithHttpRootTest.java +++ b/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/UriTagWithHttpRootTest.java @@ -44,6 +44,14 @@ public class UriTagWithHttpRootTest { @Inject MeterRegistry registry; + @Test + public void testClient() throws InterruptedException { + when().get("/ping/one").then().statusCode(200); + Util.waitForMeters(registry.find("http.server.requests").timers(), 1); + Util.waitForMeters(registry.find("http.client.requests").timers(), 1); + Assertions.assertEquals(1, registry.find("http.client.requests").tag("uri", "/pong/{message}").timers().size()); + } + @Test public void testRequestUris() throws Exception { RestAssured.basePath = "/"; diff --git a/extensions/mongodb-client/deployment/pom.xml b/extensions/mongodb-client/deployment/pom.xml index 8bd99d1840900..36b104c57bf4b 100644 --- a/extensions/mongodb-client/deployment/pom.xml +++ b/extensions/mongodb-client/deployment/pom.xml @@ -63,7 +63,12 @@ io.quarkus - quarkus-smallrye-metrics-deployment + quarkus-micrometer-deployment + true + + + io.quarkus + quarkus-micrometer-registry-prometheus-deployment test @@ -86,6 +91,11 @@ awaitility test + + org.mockito + mockito-core + test + org.assertj assertj-core diff --git a/extensions/mongodb-client/deployment/src/main/java/io/quarkus/mongodb/deployment/MongoClientProcessor.java b/extensions/mongodb-client/deployment/src/main/java/io/quarkus/mongodb/deployment/MongoClientProcessor.java index 6413be3720314..68003f57ea613 100644 --- a/extensions/mongodb-client/deployment/src/main/java/io/quarkus/mongodb/deployment/MongoClientProcessor.java +++ b/extensions/mongodb-client/deployment/src/main/java/io/quarkus/mongodb/deployment/MongoClientProcessor.java @@ -64,6 +64,7 @@ import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; import io.quarkus.deployment.metrics.MetricsCapabilityBuildItem; import io.quarkus.mongodb.MongoClientName; +import io.quarkus.mongodb.metrics.MicrometerCommandListener; import io.quarkus.mongodb.reactive.ReactiveMongoClient; import io.quarkus.mongodb.runtime.MongoClientBeanUtil; import io.quarkus.mongodb.runtime.MongoClientCustomizer; @@ -124,6 +125,21 @@ AdditionalIndexedClassesBuildItem includeMongoCommandListener(MongoClientBuildTi return new AdditionalIndexedClassesBuildItem(); } + @BuildStep + void includeMongoCommandMetricListener( + BuildProducer additionalIndexedClasses, + MongoClientBuildTimeConfig buildTimeConfig, + Optional metricsCapability) { + if (!buildTimeConfig.metricsEnabled) { + return; + } + boolean withMicrometer = metricsCapability.map(cap -> cap.metricsSupported(MetricsFactory.MICROMETER)) + .orElse(false); + if (withMicrometer) { + additionalIndexedClasses.produce(new AdditionalIndexedClassesBuildItem(MicrometerCommandListener.class.getName())); + } + } + @BuildStep public void registerDnsProvider(BuildProducer nativeProducer) { nativeProducer.produce(new NativeImageResourceBuildItem("META-INF/services/" + DnsClientProvider.class.getName())); diff --git a/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoLazyTest.java b/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoLazyTest.java index c8c1d01716668..10c1cae82bf3a 100644 --- a/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoLazyTest.java +++ b/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoLazyTest.java @@ -4,58 +4,47 @@ import jakarta.inject.Inject; -import org.eclipse.microprofile.metrics.Metric; -import org.eclipse.microprofile.metrics.MetricID; -import org.eclipse.microprofile.metrics.MetricRegistry; -import org.eclipse.microprofile.metrics.Tag; -import org.eclipse.microprofile.metrics.annotation.RegistryType; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import com.mongodb.client.MongoClient; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; import io.quarkus.arc.Arc; -import io.quarkus.mongodb.metrics.ConnectionPoolGauge; import io.quarkus.mongodb.reactive.ReactiveMongoClient; import io.quarkus.test.QuarkusUnitTest; /** Variation of {@link io.quarkus.mongodb.MongoMetricsTest} to verify lazy client initialization. */ -public class MongoLazyTest extends MongoTestBase { +class MongoLazyTest extends MongoTestBase { @Inject - @RegistryType(type = MetricRegistry.Type.VENDOR) - MetricRegistry registry; + MeterRegistry meterRegistry; @RegisterExtension static final QuarkusUnitTest config = new QuarkusUnitTest() - .withApplicationRoot((jar) -> jar.addClasses(MongoTestBase.class)) + .withApplicationRoot(jar -> jar.addClasses(MongoTestBase.class)) .withConfigurationResource("application-metrics-mongo.properties"); @Test void testLazyClientCreation() { // Clients are created lazily, this metric should not be present yet - assertThat(getGaugeValueOrNull("mongodb.connection-pool.size", getTags())).isNull(); - assertThat(getGaugeValueOrNull("mongodb.connection-pool.checked-out-count", getTags())).isNull(); + assertThat(getMetric("mongodb.driver.pool.size")).isNull(); + assertThat(getMetric("mongodb.driver.pool.checkedout")).isNull(); + assertThat(getMetric("mongodb.driver.commands")).isNull(); // doing this here instead of in another method in order to avoid messing with the initialization stats assertThat(Arc.container().instance(MongoClient.class).get()).isNull(); assertThat(Arc.container().instance(ReactiveMongoClient.class).get()).isNull(); } - private Long getGaugeValueOrNull(String metricName, Tag[] tags) { - MetricID metricID = new MetricID(metricName, tags); - Metric metric = registry.getMetrics().get(metricID); - - if (metric == null) { - return null; - } - return ((ConnectionPoolGauge) metric).getValue(); + private Double getMetric(String name) { + Meter metric = meterRegistry.getMeters() + .stream() + .filter(mtr -> mtr.getId().getName().contains(name)) + .findFirst() + .orElse(null); + return metric == null ? null : metric.measure().iterator().next().getValue(); } - private Tag[] getTags() { - return new Tag[] { - new Tag("host", "127.0.0.1"), - new Tag("port", "27018"), - }; - } } diff --git a/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoMetricsTest.java b/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoMetricsTest.java index dab2253817afe..cc54cb1b61d8a 100644 --- a/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoMetricsTest.java +++ b/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoMetricsTest.java @@ -1,38 +1,32 @@ package io.quarkus.mongodb; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; import jakarta.inject.Inject; -import org.eclipse.microprofile.metrics.Metric; -import org.eclipse.microprofile.metrics.MetricID; -import org.eclipse.microprofile.metrics.MetricRegistry; -import org.eclipse.microprofile.metrics.Tag; -import org.eclipse.microprofile.metrics.annotation.RegistryType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import com.mongodb.client.MongoClient; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; import io.quarkus.arc.Arc; -import io.quarkus.mongodb.metrics.ConnectionPoolGauge; import io.quarkus.mongodb.reactive.ReactiveMongoClient; import io.quarkus.test.QuarkusUnitTest; -public class MongoMetricsTest extends MongoTestBase { +class MongoMetricsTest extends MongoTestBase { @Inject MongoClient client; @Inject - @RegistryType(type = MetricRegistry.Type.VENDOR) - MetricRegistry registry; + MeterRegistry meterRegistry; @RegisterExtension static final QuarkusUnitTest config = new QuarkusUnitTest() - .withApplicationRoot((jar) -> jar.addClasses(MongoTestBase.class)) + .withApplicationRoot(jar -> jar.addClasses(MongoTestBase.class)) .withConfigurationResource("application-metrics-mongo.properties"); @AfterEach @@ -45,38 +39,32 @@ void cleanup() { @Test void testMetricsInitialization() { // Clients are created lazily, this metric should not be present yet - assertThat(getGaugeValueOrNull("mongodb.connection-pool.size", getTags())).isNull(); - assertThat(getGaugeValueOrNull("mongodb.connection-pool.checked-out-count", getTags())).isNull(); + assertThat(getMetric("mongodb.driver.pool.size")).isNull(); + assertThat(getMetric("mongodb.driver.pool.checkedout")).isNull(); // Just need to execute something so that a connection is opened String name = client.listDatabaseNames().first(); - assertEquals(1L, getGaugeValueOrNull("mongodb.connection-pool.size", getTags())); - assertEquals(0L, getGaugeValueOrNull("mongodb.connection-pool.checked-out-count", getTags())); + assertThat(getMetric("mongodb.driver.pool.size")).isOne(); + assertThat(getMetric("mongodb.driver.commands")).isOne(); + assertThat(getMetric("mongodb.driver.pool.checkedout")).isZero(); client.close(); - assertEquals(0L, getGaugeValueOrNull("mongodb.connection-pool.size", getTags())); - assertEquals(0L, getGaugeValueOrNull("mongodb.connection-pool.checked-out-count", getTags())); + assertThat(getMetric("mongodb.driver.pool.size")).isNull(); + assertThat(getMetric("mongodb.driver.pool.checkedout")).isNull(); // doing this here instead of in another method in order to avoid messing with the initialization stats assertThat(Arc.container().instance(MongoClient.class).get()).isNotNull(); assertThat(Arc.container().instance(ReactiveMongoClient.class).get()).isNull(); } - private Long getGaugeValueOrNull(String metricName, Tag[] tags) { - MetricID metricID = new MetricID(metricName, tags); - Metric metric = registry.getMetrics().get(metricID); - - if (metric == null) { - return null; - } - return ((ConnectionPoolGauge) metric).getValue(); + private Double getMetric(String metricName) { + Meter metric = meterRegistry.getMeters() + .stream() + .filter(mtr -> mtr.getId().getName().contains(metricName)) + .findFirst() + .orElse(null); + return metric == null ? null : metric.measure().iterator().next().getValue(); } - private Tag[] getTags() { - return new Tag[] { - new Tag("host", "127.0.0.1"), - new Tag("port", "27018"), - }; - } } diff --git a/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/deployment/MongoClientProcessorTest.java b/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/deployment/MongoClientProcessorTest.java new file mode 100644 index 0000000000000..ad437dc2dc882 --- /dev/null +++ b/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/deployment/MongoClientProcessorTest.java @@ -0,0 +1,59 @@ +package io.quarkus.mongodb.deployment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; + +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.builditem.AdditionalIndexedClassesBuildItem; +import io.quarkus.deployment.metrics.MetricsCapabilityBuildItem; +import io.quarkus.runtime.metrics.MetricsFactory; + +class MongoClientProcessorTest { + private final MongoClientProcessor buildStep = new MongoClientProcessor(); + + @SuppressWarnings("unchecked") + @ParameterizedTest + @CsvSource({ + "true, true, true", // Metrics enabled and Micrometer supported + "true, false, false", // Metrics enabled but Micrometer not supported + "false, true, false", // Metrics disabled and Micrometer supported + "false, false, false" // Metrics disabled and Micrometer not supported + }) + void testIncludeMongoCommandMetricListener(boolean metricsEnabled, boolean micrometerSupported, boolean expectedResult) { + MongoClientBuildTimeConfig config = config(metricsEnabled); + Optional capability = capability(metricsEnabled, micrometerSupported); + + BuildProducer buildProducer = mock(BuildProducer.class); + buildStep.includeMongoCommandMetricListener(buildProducer, config, capability); + + if (expectedResult) { + var captor = ArgumentCaptor.forClass(AdditionalIndexedClassesBuildItem.class); + verify(buildProducer, times(1)).produce(captor.capture()); + assertThat(captor.getAllValues().get(0).getClassesToIndex()) + .containsExactly("io.quarkus.mongodb.metrics.MicrometerCommandListener"); + } else { + verify(buildProducer, never()).produce(any(AdditionalIndexedClassesBuildItem.class)); + } + } + + private static Optional capability(boolean metricsEnabled, boolean micrometerSupported) { + MetricsCapabilityBuildItem capability = metricsEnabled + ? new MetricsCapabilityBuildItem(factory -> MetricsFactory.MICROMETER.equals(factory) && micrometerSupported) + : null; + return Optional.ofNullable(capability); + } + + private static MongoClientBuildTimeConfig config(boolean metricsEnabled) { + MongoClientBuildTimeConfig buildTimeConfig = new MongoClientBuildTimeConfig(); + buildTimeConfig.metricsEnabled = metricsEnabled; + return buildTimeConfig; + } + +} diff --git a/extensions/mongodb-client/runtime/src/main/java/io/quarkus/mongodb/metrics/MicrometerCommandListener.java b/extensions/mongodb-client/runtime/src/main/java/io/quarkus/mongodb/metrics/MicrometerCommandListener.java new file mode 100644 index 0000000000000..4ee642491a9c7 --- /dev/null +++ b/extensions/mongodb-client/runtime/src/main/java/io/quarkus/mongodb/metrics/MicrometerCommandListener.java @@ -0,0 +1,14 @@ +package io.quarkus.mongodb.metrics; + +import jakarta.inject.Inject; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.mongodb.MongoMetricsCommandListener; + +public class MicrometerCommandListener extends MongoMetricsCommandListener { + @Inject + public MicrometerCommandListener(MeterRegistry registry) { + super(registry); + } + +} diff --git a/extensions/oidc-client-registration/deployment/src/main/java/io/quarkus/oidc/client/registration/deployment/devservices/keycloak/KeycloakDevServiceRequiredBuildStep.java b/extensions/oidc-client-registration/deployment/src/main/java/io/quarkus/oidc/client/registration/deployment/devservices/keycloak/KeycloakDevServiceRequiredBuildStep.java index 5ba594617a5b6..bff9cd44584b0 100644 --- a/extensions/oidc-client-registration/deployment/src/main/java/io/quarkus/oidc/client/registration/deployment/devservices/keycloak/KeycloakDevServiceRequiredBuildStep.java +++ b/extensions/oidc-client-registration/deployment/src/main/java/io/quarkus/oidc/client/registration/deployment/devservices/keycloak/KeycloakDevServiceRequiredBuildStep.java @@ -1,7 +1,5 @@ package io.quarkus.oidc.client.registration.deployment.devservices.keycloak; -import static io.quarkus.devservices.keycloak.KeycloakDevServicesRequiredBuildItem.OIDC_AUTH_SERVER_URL_CONFIG_KEY; - import java.util.List; import java.util.Map; @@ -55,8 +53,7 @@ public void customizeDefaultRealm(RealmRepresentation realmRepresentation) { } }; - return KeycloakDevServicesRequiredBuildItem.of(devServicesConfigurator, - OIDC_CLIENT_REG_AUTH_SERVER_URL_CONFIG_KEY, OIDC_AUTH_SERVER_URL_CONFIG_KEY); + return KeycloakDevServicesRequiredBuildItem.of(devServicesConfigurator, OIDC_CLIENT_REG_AUTH_SERVER_URL_CONFIG_KEY); } @BuildStep(onlyIf = IsDevelopment.class) diff --git a/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/devservices/keycloak/KeycloakDevServiceRequiredBuildStep.java b/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/devservices/keycloak/KeycloakDevServiceRequiredBuildStep.java index 099afd16de833..5ca43f77ad4bd 100644 --- a/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/devservices/keycloak/KeycloakDevServiceRequiredBuildStep.java +++ b/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/devservices/keycloak/KeycloakDevServiceRequiredBuildStep.java @@ -1,7 +1,5 @@ package io.quarkus.oidc.client.deployment.devservices.keycloak; -import static io.quarkus.devservices.keycloak.KeycloakDevServicesRequiredBuildItem.OIDC_AUTH_SERVER_URL_CONFIG_KEY; - import java.util.HashMap; import io.quarkus.deployment.IsDevelopment; @@ -35,7 +33,7 @@ KeycloakDevServicesRequiredBuildItem requireKeycloakDevService(KeycloakDevServic configProperties.put(OIDC_CLIENT_SECRET_CONFIG_KEY, ctx.oidcClientSecret()); } return configProperties; - }, OIDC_CLIENT_AUTH_SERVER_URL_CONFIG_KEY, OIDC_CLIENT_TOKEN_PATH_CONFIG_KEY, OIDC_AUTH_SERVER_URL_CONFIG_KEY); + }, OIDC_CLIENT_AUTH_SERVER_URL_CONFIG_KEY, OIDC_CLIENT_TOKEN_PATH_CONFIG_KEY); } @BuildStep(onlyIf = IsDevelopment.class) diff --git a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/NamedOidcClientInjectionTestCase.java b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/NamedOidcClientInjectionTestCase.java index e06687718effd..a164f65178c57 100644 --- a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/NamedOidcClientInjectionTestCase.java +++ b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/NamedOidcClientInjectionTestCase.java @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.oidc.runtime.OidcUtils; +import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.test.QuarkusUnitTest; import io.quarkus.test.common.QuarkusTestResource; import io.restassured.RestAssured; @@ -48,7 +48,7 @@ private void validateTokens(String token1, String token2) { } private String preferredUserOf(String token) { - return OidcUtils.decodeJwtContent(token).getString("preferred_username"); + return OidcCommonUtils.decodeJwtContent(token).getString("preferred_username"); } private String doTestGetTokenByNamedClient(String clientId) { diff --git a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientKeycloakDevServiceStartupTest.java b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientKeycloakDevServiceStartupTest.java new file mode 100644 index 0000000000000..bbb6243429eda --- /dev/null +++ b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientKeycloakDevServiceStartupTest.java @@ -0,0 +1,33 @@ +package io.quarkus.oidc.client; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +/** + * Test Keycloak Dev Service is not started when known social provider is configured + * in Quarkus OIDC extension. + */ +public class OidcClientKeycloakDevServiceStartupTest { + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar + .addAsResource(new StringAsset(""" + quarkus.oidc.provider=slack + quarkus.oidc.client-id=irrelevant-client-id + """), "application.properties")) + .setLogRecordPredicate(logRecord -> logRecord != null && logRecord.getMessage() != null + && logRecord.getMessage().contains("Dev Services for Keycloak started")) + .assertLogRecords(logRecords -> assertTrue(logRecords.isEmpty())); + + @Test + public void testDevServiceNotStarted() { + // needs to be here so that log asserter runs after all tests + } + +} diff --git a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientKeycloakDevServiceTest.java b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientKeycloakDevServiceTest.java index 8a2e94cb42caa..16895e0e833e2 100644 --- a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientKeycloakDevServiceTest.java +++ b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientKeycloakDevServiceTest.java @@ -13,7 +13,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.oidc.client.runtime.OidcClientsConfig; -import io.quarkus.oidc.runtime.OidcUtils; +import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.test.QuarkusUnitTest; import io.restassured.RestAssured; @@ -67,7 +67,7 @@ private void validateTokens(String token1, String token2) { } private String upn(String token) { - return OidcUtils.decodeJwtContent(token).getString("upn"); + return OidcCommonUtils.decodeJwtContent(token).getString("upn"); } private String doTestGetTokenByNamedClient(String clientId) { diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java index 3b820603f7d3d..04f0bc32c11cc 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java @@ -22,11 +22,13 @@ import io.quarkus.oidc.common.OidcRequestFilter; import io.quarkus.oidc.common.OidcRequestFilter.OidcRequestContext; import io.quarkus.oidc.common.OidcResponseFilter; +import io.quarkus.oidc.common.runtime.ClientAssertionProvider; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.oidc.common.runtime.config.OidcClientCommonConfig.Credentials.Jwt.Source; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.groups.UniOnItem; +import io.vertx.core.Vertx; import io.vertx.core.http.HttpHeaders; import io.vertx.core.json.DecodeException; import io.vertx.core.json.JsonObject; @@ -55,12 +57,13 @@ public class OidcClientImpl implements OidcClient { private final OidcClientConfig oidcConfig; private final Map> requestFilters; private final Map> responseFilters; + private final ClientAssertionProvider clientAssertionProvider; private volatile boolean closed; - public OidcClientImpl(WebClient client, String tokenRequestUri, String tokenRevokeUri, String grantType, + OidcClientImpl(WebClient client, String tokenRequestUri, String tokenRevokeUri, String grantType, MultiMap tokenGrantParams, MultiMap commonRefreshGrantParams, OidcClientConfig oidcClientConfig, Map> requestFilters, - Map> responseFilters) { + Map> responseFilters, Vertx vertx) { this.client = client; this.tokenRequestUri = tokenRequestUri; this.tokenRevokeUri = tokenRevokeUri; @@ -73,6 +76,16 @@ public OidcClientImpl(WebClient client, String tokenRequestUri, String tokenRevo this.clientSecretBasicAuthScheme = OidcCommonUtils.initClientSecretBasicAuth(oidcClientConfig); this.jwtBearerAuthentication = oidcClientConfig.credentials().jwt().source() == Source.BEARER; this.clientJwtKey = jwtBearerAuthentication ? null : OidcCommonUtils.initClientJwtKey(oidcClientConfig, false); + if (jwtBearerAuthentication && oidcClientConfig.credentials().jwt().tokenPath().isPresent()) { + this.clientAssertionProvider = new ClientAssertionProvider(vertx, + oidcClientConfig.credentials().jwt().tokenPath().get()); + if (this.clientAssertionProvider.getClientAssertion() == null) { + throw new OidcClientException("Cannot find a valid JWT bearer token at path: " + + oidcClientConfig.credentials().jwt().tokenPath().get()); + } + } else { + this.clientAssertionProvider = null; + } } @Override @@ -177,7 +190,14 @@ private UniOnItem> postRequest( if (clientSecretBasicAuthScheme != null) { request.putHeader(AUTHORIZATION_HEADER, clientSecretBasicAuthScheme); } else if (jwtBearerAuthentication) { - if (!additionalGrantParameters.containsKey(OidcConstants.CLIENT_ASSERTION)) { + String clientAssertion = additionalGrantParameters.get(OidcConstants.CLIENT_ASSERTION); + if (clientAssertion == null && clientAssertionProvider != null) { + clientAssertion = clientAssertionProvider.getClientAssertion(); + if (clientAssertion != null) { + body.add(OidcConstants.CLIENT_ASSERTION, clientAssertion); + } + } + if (clientAssertion == null) { String errorMessage = String.format( "%s OidcClient can not complete the %s grant request because a JWT bearer client_assertion is missing", oidcConfig.id().get(), (refresh ? OidcConstants.REFRESH_TOKEN_GRANT : grantType)); @@ -319,6 +339,9 @@ private static MultiMap copyMultiMap(MultiMap oldMap) { public void close() throws IOException { if (!closed) { client.close(); + if (clientAssertionProvider != null) { + clientAssertionProvider.close(); + } closed = true; } } diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java index 5b9c1e6f64faf..86603aeba0015 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java @@ -218,11 +218,8 @@ public OidcClient apply(OidcConfigurationMetadata metadata, Throwable t) { setGrantClientParams(oidcConfig, commonRefreshGrantParams, OidcConstants.REFRESH_TOKEN_GRANT); return new OidcClientImpl(client, metadata.tokenRequestUri, metadata.tokenRevokeUri, grantType, - tokenGrantParams, - commonRefreshGrantParams, - oidcConfig, - oidcRequestFilters, - oidcResponseFilters); + tokenGrantParams, commonRefreshGrantParams, oidcConfig, oidcRequestFilters, + oidcResponseFilters, vertx.get()); } }); diff --git a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigBuilderTest.java b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigBuilderTest.java index e32ffb2873c44..5de44331d95f8 100644 --- a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigBuilderTest.java +++ b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigBuilderTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.file.Path; import java.time.Duration; import java.util.List; import java.util.Map; @@ -84,6 +85,7 @@ public void testDefaultValues() { assertTrue(jwt.signatureAlgorithm().isEmpty()); assertEquals(10, jwt.lifespan()); assertFalse(jwt.assertion()); + assertFalse(jwt.tokenPath().isPresent()); // OidcCommonConfig methods assertTrue(config.authServerUrl().isEmpty()); @@ -154,6 +156,7 @@ public void testSetEveryProperty() { .end() .jwt() .source(Source.BEARER) + .tokenPath(Path.of("janitor")) .secretProvider() .keyringName("jwt-keyring-name-yep") .key("jwt-key-yep") @@ -249,6 +252,7 @@ public void testSetEveryProperty() { assertNotNull(jwt); assertEquals(Source.BEARER, jwt.source()); assertEquals("jwt-secret-yep", jwt.secret().orElse(null)); + assertEquals("janitor", jwt.tokenPath().map(Path::toString).orElse(null)); provider = jwt.secretProvider(); assertNotNull(provider); assertEquals("jwt-keyring-name-yep", provider.keyringName().orElse(null)); @@ -460,6 +464,7 @@ public void testCopyOidcClientCommonConfigProperties() { .end() .jwt() .source(Source.BEARER) + .tokenPath(Path.of("robot")) .secretProvider() .keyringName("jwt-keyring-name-yep") .key("jwt-key-yep") @@ -507,6 +512,7 @@ public void testCopyOidcClientCommonConfigProperties() { assertNotNull(jwt); assertEquals(Source.BEARER, jwt.source()); assertEquals("jwt-secret-yep", jwt.secret().orElse(null)); + assertEquals("robot", jwt.tokenPath().map(Path::toString).orElse(null)); provider = jwt.secretProvider(); assertNotNull(provider); assertEquals("jwt-keyring-name-yep", provider.keyringName().orElse(null)); diff --git a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigImpl.java b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigImpl.java index b786c83e4237a..895e1478a1053 100644 --- a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigImpl.java +++ b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigImpl.java @@ -89,7 +89,8 @@ enum ConfigMappingMethods { CREDENTIALS_JWT_LIFESPAN, CREDENTIALS_JWT_ASSERTION, CREDENTIALS_JWT_AUDIENCE, - CREDENTIALS_JWT_TOKEN_ID + CREDENTIALS_JWT_TOKEN_ID, + JWT_BEARER_TOKEN_PATH } final Map invocationsRecorder = new EnumMap<>(ConfigMappingMethods.class); @@ -182,6 +183,12 @@ public Source source() { return Source.BEARER; } + @Override + public Optional tokenPath() { + invocationsRecorder.put(ConfigMappingMethods.JWT_BEARER_TOKEN_PATH, true); + return Optional.empty(); + } + @Override public Optional secret() { invocationsRecorder.put(ConfigMappingMethods.CREDENTIALS_JWT_SECRET, true); diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/OidcRequestContextProperties.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/OidcRequestContextProperties.java index eda5b00cd66d3..45c0918cc7bef 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/OidcRequestContextProperties.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/OidcRequestContextProperties.java @@ -1,6 +1,7 @@ package io.quarkus.oidc.common; import java.util.Collections; +import java.util.HashMap; import java.util.Map; public class OidcRequestContextProperties { @@ -16,7 +17,7 @@ public OidcRequestContextProperties() { } public OidcRequestContextProperties(Map properties) { - this.properties = properties; + this.properties = new HashMap<>(properties); } /** diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/ClientAssertionProvider.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/ClientAssertionProvider.java new file mode 100644 index 0000000000000..f5b8cd0a29d63 --- /dev/null +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/ClientAssertionProvider.java @@ -0,0 +1,109 @@ +package io.quarkus.oidc.common.runtime; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.eclipse.microprofile.jwt.Claims; +import org.jboss.logging.Logger; + +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; + +public final class ClientAssertionProvider implements Closeable { + + private record ClientAssertion(String bearerToken, long expiresAt, long timerId) { + private boolean isInvalid() { + final long nowSecs = System.currentTimeMillis() / 1000; + return nowSecs > expiresAt; + } + } + + private static final Logger LOG = Logger.getLogger(ClientAssertionProvider.class); + private final Vertx vertx; + private final Path bearerTokenPath; + private volatile ClientAssertion clientAssertion; + + public ClientAssertionProvider(Vertx vertx, Path bearerTokenPath) { + this.vertx = vertx; + this.bearerTokenPath = bearerTokenPath; + this.clientAssertion = loadFromFileSystem(); + } + + public String getClientAssertion() { + ClientAssertion clientAssertion = this.clientAssertion; + if (isInvalid(clientAssertion)) { + clientAssertion = loadClientAssertion(); + } + return clientAssertion == null ? null : clientAssertion.bearerToken; + } + + @Override + public void close() { + cancelRefresh(); + clientAssertion = null; + } + + private synchronized ClientAssertion loadClientAssertion() { + if (isInvalid(clientAssertion)) { + cancelRefresh(); + clientAssertion = loadFromFileSystem(); + } + return clientAssertion; + } + + private long scheduleRefresh(long expiresAt) { + // in K8 and OCP, tokens are proactively rotated at 80 % of their TTL + long delay = (long) (expiresAt * 0.85); + return vertx.setTimer(delay, new Handler() { + @Override + public void handle(Long ignored) { + ClientAssertionProvider.this.clientAssertion = loadFromFileSystem(); + } + }); + } + + private void cancelRefresh() { + if (clientAssertion != null) { + vertx.cancelTimer(clientAssertion.timerId); + } + } + + private ClientAssertion loadFromFileSystem() { + if (Files.exists(bearerTokenPath)) { + try { + String bearerToken = Files.readString(bearerTokenPath).trim(); + Long expiresAt = getExpiresAtFromExpClaim(bearerToken); + if (expiresAt != null) { + return new ClientAssertion(bearerToken, expiresAt, scheduleRefresh(expiresAt)); + } else { + LOG.error("Bearer token or its expiry claim is invalid"); + } + } catch (IOException e) { + LOG.error("Failed to read file with a bearer token at path: " + bearerTokenPath, e); + } + } else { + LOG.warn("Cannot find a file with a bearer token at path: " + bearerTokenPath); + } + return null; + } + + private static boolean isInvalid(ClientAssertion clientAssertion) { + return clientAssertion == null || clientAssertion.isInvalid(); + } + + private static Long getExpiresAtFromExpClaim(String bearerToken) { + JsonObject claims = OidcCommonUtils.decodeJwtContent(bearerToken); + if (claims == null || !claims.containsKey(Claims.exp.name())) { + return null; + } + try { + return claims.getLong(Claims.exp.name()); + } catch (IllegalArgumentException ex) { + LOG.debug("Bearer token expiry claim can not be converted to Long"); + return null; + } + } +} diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcClientCommonConfig.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcClientCommonConfig.java index c7fa4e77f4882..07b7e6b61afc5 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcClientCommonConfig.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcClientCommonConfig.java @@ -1,13 +1,10 @@ package io.quarkus.oidc.common.runtime; +import java.nio.file.Path; import java.util.HashMap; import java.util.Map; import java.util.Optional; -/** - * @deprecated use the {@link io.quarkus.oidc.common.runtime.config.OidcClientCommonConfig} interface instead - */ -@Deprecated(since = "3.18") public abstract class OidcClientCommonConfig extends OidcCommonConfig implements io.quarkus.oidc.common.runtime.config.OidcClientCommonConfig { @@ -283,6 +280,11 @@ public io.quarkus.oidc.common.runtime.config.OidcClientCommonConfig.Credentials. .valueOf(source.toString()); } + @Override + public Optional tokenPath() { + return tokenPath; + } + @Override public Optional secret() { return secret; @@ -363,6 +365,8 @@ public boolean assertion() { return assertion; } + private Optional tokenPath = Optional.empty(); + public static enum Source { // JWT token is generated by the OIDC provider client to support // `client_secret_jwt` and `private_key_jwt` authentication methods @@ -578,6 +582,7 @@ private void addConfigMappingValues( signatureAlgorithm = mapping.signatureAlgorithm(); lifespan = mapping.lifespan(); assertion = mapping.assertion(); + tokenPath = mapping.tokenPath(); } } diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java index 978c116d80827..705f23329a0d2 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java @@ -5,10 +5,6 @@ import java.util.Optional; import java.util.OptionalInt; -/** - * @deprecated use the {@link io.quarkus.oidc.common.runtime.config.OidcCommonConfig} interface instead - */ -@Deprecated(since = "3.18") public abstract class OidcCommonConfig implements io.quarkus.oidc.common.runtime.config.OidcCommonConfig { public OidcCommonConfig() { diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java index 3be6c684f063d..224ad6af5ab78 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java @@ -24,6 +24,7 @@ import java.util.Optional; import java.util.OptionalInt; import java.util.Set; +import java.util.StringTokenizer; import java.util.concurrent.Callable; import java.util.function.Function; import java.util.function.Predicate; @@ -121,6 +122,14 @@ public static void verifyCommonConfiguration(OidcClientCommonConfig oidcConfig, "Use only '%1$scredentials.secret' or '%1$scredentials.client-secret' or '%1$scredentials.jwt.secret' property", configPrefix)); } + Credentials.Jwt jwt = creds.jwt(); + if (jwt.source() == Credentials.Jwt.Source.BEARER) { + if (isServerConfig && jwt.tokenPath().isEmpty()) { + throw new ConfigurationException("Bearer token path must be set when the JWT source is a bearer token"); + } + } else if (jwt.tokenPath().isPresent()) { + throw new ConfigurationException("Bearer token path can only be set when the JWT source is a bearer token"); + } } public static String prependSlash(String path) { @@ -743,4 +752,41 @@ public Uni> apply(Void unused) { return request.send(); } } + + public static JsonObject decodeJwtContent(String jwt) { + String encodedContent = getJwtContentPart(jwt); + if (encodedContent == null) { + return null; + } + return decodeAsJsonObject(encodedContent); + } + + public static String getJwtContentPart(String jwt) { + StringTokenizer tokens = new StringTokenizer(jwt, "."); + // part 1: skip the token headers + tokens.nextToken(); + if (!tokens.hasMoreTokens()) { + return null; + } + // part 2: token content + String encodedContent = tokens.nextToken(); + + // let's check only 1 more signature part is available + if (tokens.countTokens() != 1) { + return null; + } + return encodedContent; + } + + public static String base64UrlDecode(String encodedContent) { + return new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8); + } + + public static JsonObject decodeAsJsonObject(String encodedContent) { + try { + return new JsonObject(base64UrlDecode(encodedContent)); + } catch (IllegalArgumentException ex) { + return null; + } + } } diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfig.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfig.java index 07318e9f4c18a..58b02bdf81ec0 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfig.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfig.java @@ -1,5 +1,6 @@ package io.quarkus.oidc.common.runtime.config; +import java.nio.file.Path; import java.util.Map; import java.util.Optional; @@ -132,11 +133,14 @@ enum Method { interface Jwt { enum Source { - // JWT token is generated by the OIDC provider client to support - // `client_secret_jwt` and `private_key_jwt` authentication methods + /** + * JWT token is generated by the OIDC provider client to support + * `client_secret_jwt` and `private_key_jwt` authentication methods. + */ CLIENT, - // JWT bearer token as used as a client assertion: https://www.rfc-editor.org/rfc/rfc7523#section-2.2 - // This option is only supported by the OIDC client extension. + /** + * JWT bearer token is used as a client assertion: https://www.rfc-editor.org/rfc/rfc7523#section-2.2. + */ BEARER } @@ -146,6 +150,12 @@ enum Source { @WithDefault("client") Source source(); + /** + * Path to a file with a JWT bearer token that should be used as a client assertion. + * This path can only be set when JWT source ({@link #source()}) is set to {@link Source#BEARER}. + */ + Optional tokenPath(); + /** * If provided, indicates that JWT is signed using a secret key. * It is mutually exclusive with {@link #key}, {@link #keyFile} and {@link #keyStore} properties. diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfigBuilder.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfigBuilder.java index 3e3112d0bd858..643fa456cae2e 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfigBuilder.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfigBuilder.java @@ -1,5 +1,6 @@ package io.quarkus.oidc.common.runtime.config; +import java.nio.file.Path; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -472,7 +473,8 @@ private record JwtImpl(Source source, Optional secret, Provider secretPr Optional keyFile, Optional keyStoreFile, Optional keyStorePassword, Optional keyId, Optional keyPassword, Optional audience, Optional tokenKeyId, Optional issuer, Optional subject, Map claims, - Optional signatureAlgorithm, int lifespan, boolean assertion) implements Jwt { + Optional signatureAlgorithm, int lifespan, boolean assertion, + Optional tokenPath) implements Jwt { } @@ -492,6 +494,7 @@ private record JwtImpl(Source source, Optional secret, Provider secretPr private Optional issuer; private Optional subject; private Optional signatureAlgorithm; + private Optional tokenPath; private int lifespan; private boolean assertion; @@ -513,6 +516,7 @@ public JwtBuilder() { this.signatureAlgorithm = Optional.empty(); this.lifespan = 10; this.assertion = false; + this.tokenPath = Optional.empty(); } public JwtBuilder(CredentialsBuilder builder) { @@ -538,6 +542,16 @@ private JwtBuilder(CredentialsBuilder builder, Jwt jwt) { this.signatureAlgorithm = jwt.signatureAlgorithm(); this.lifespan = jwt.lifespan(); this.assertion = jwt.assertion(); + this.tokenPath = jwt.tokenPath(); + } + + /** + * @param tokenPath {@link Jwt#tokenPath()} + * @return this builder + */ + public JwtBuilder tokenPath(Path tokenPath) { + this.tokenPath = Optional.ofNullable(tokenPath); + return this; } /** @@ -741,7 +755,8 @@ public T endCredentials() { */ public Jwt build() { return new JwtImpl(source, secret, secretProvider, key, keyFile, keyStoreFile, keyStorePassword, keyId, keyPassword, - audience, tokenKeyId, issuer, subject, Map.copyOf(claims), signatureAlgorithm, lifespan, assertion); + audience, tokenKeyId, issuer, subject, Map.copyOf(claims), signatureAlgorithm, lifespan, assertion, + tokenPath); } } } diff --git a/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/ClientAssertionProviderTest.java b/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/ClientAssertionProviderTest.java new file mode 100644 index 0000000000000..6c340226da6e0 --- /dev/null +++ b/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/ClientAssertionProviderTest.java @@ -0,0 +1,59 @@ +package io.quarkus.oidc.common.runtime; + +import static java.nio.file.StandardOpenOption.CREATE; +import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; +import static java.nio.file.StandardOpenOption.WRITE; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; + +import io.smallrye.jwt.build.Jwt; +import io.vertx.core.Vertx; + +public class ClientAssertionProviderTest { + + @Test + public void testJwtBearerTokenRefresh() { + // if this test ever gets flaky, we will need longer expires in and waiting between storing / refresh + Vertx vertx = Vertx.vertx(); + Path jwtBearerTokenPath = Path.of("target").resolve("jwt-bearer-token.json"); + String jwtBearerToken = createJwtBearerToken(); + storeNewJwtBearerToken(jwtBearerTokenPath, jwtBearerToken); + try (var clientAssertionProvider = new ClientAssertionProvider(vertx, jwtBearerTokenPath)) { + // assert first token is loaded + assertEquals(jwtBearerToken, clientAssertionProvider.getClientAssertion()); + + // create a new token + String secondJwtBearerToken = createJwtBearerToken(); + storeNewJwtBearerToken(jwtBearerTokenPath, secondJwtBearerToken); + + Awaitility.await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> assertEquals(secondJwtBearerToken, clientAssertionProvider.getClientAssertion())); + } finally { + vertx.close().toCompletionStage().toCompletableFuture().join(); + } + } + + private static void storeNewJwtBearerToken(Path jwtBearerTokenPath, String jwtBearerToken) { + try { + Files.writeString(jwtBearerTokenPath, jwtBearerToken, TRUNCATE_EXISTING, CREATE, WRITE); + } catch (IOException e) { + throw new RuntimeException("Failed to write JWT bearer token", e); + } + } + + private static String createJwtBearerToken() { + return Jwt.preferredUserName("Arnold") + .issuer("https://server.example.com") + .audience("https://service.example.com") + .expiresIn(Duration.ofSeconds(4)) + .signWithSecret("43".repeat(20)); + } + +} diff --git a/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/OidcRequestContextPropertiesTest.java b/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/OidcRequestContextPropertiesTest.java new file mode 100644 index 0000000000000..4402fe3e6e6a9 --- /dev/null +++ b/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/OidcRequestContextPropertiesTest.java @@ -0,0 +1,31 @@ +package io.quarkus.oidc.common.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.quarkus.oidc.common.OidcRequestContextProperties; + +public class OidcRequestContextPropertiesTest { + + @Test + public void testModifyPropertiesDefaultConstructor() throws Exception { + OidcRequestContextProperties props = new OidcRequestContextProperties(); + assertNull(props.get("a")); + props.put("a", "value"); + assertEquals("value", props.get("a")); + } + + @Test + public void testModifyExistinProperties() throws Exception { + OidcRequestContextProperties props = new OidcRequestContextProperties(Map.of("a", "value")); + assertEquals("value", props.get("a")); + props.put("a", "avalue"); + assertEquals("avalue", props.get("a")); + props.put("b", "bvalue"); + assertEquals("bvalue", props.get("b")); + } +} diff --git a/extensions/oidc/deployment/pom.xml b/extensions/oidc/deployment/pom.xml index 8691bf75f1911..8e8796c16474f 100644 --- a/extensions/oidc/deployment/pom.xml +++ b/extensions/oidc/deployment/pom.xml @@ -93,6 +93,11 @@ quarkus-elytron-security-properties-file-deployment test + + io.smallrye.certs + smallrye-certificate-generator-junit5 + test + diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevServiceRequiredBuildStep.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevServiceRequiredBuildStep.java index a9ac5fa48a7cd..3087bdc421efd 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevServiceRequiredBuildStep.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevServiceRequiredBuildStep.java @@ -21,7 +21,6 @@ public class KeycloakDevServiceRequiredBuildStep { private static final Logger LOG = Logger.getLogger(KeycloakDevServiceRequiredBuildStep.class); private static final String CONFIG_PREFIX = "quarkus.oidc."; private static final String TENANT_ENABLED_CONFIG_KEY = CONFIG_PREFIX + "tenant-enabled"; - private static final String PROVIDER_CONFIG_KEY = CONFIG_PREFIX + "provider"; private static final String APPLICATION_TYPE_CONFIG_KEY = CONFIG_PREFIX + "application-type"; private static final String CLIENT_ID_CONFIG_KEY = CONFIG_PREFIX + "client-id"; private static final String CLIENT_SECRET_CONFIG_KEY = CONFIG_PREFIX + "credentials.secret"; @@ -42,7 +41,7 @@ KeycloakDevServicesRequiredBuildItem requireKeycloakDevService(KeycloakDevServic configProperties.put(CLIENT_SECRET_CONFIG_KEY, ctx.oidcClientSecret()); } return configProperties; - }, OIDC_AUTH_SERVER_URL_CONFIG_KEY, PROVIDER_CONFIG_KEY); + }, OIDC_AUTH_SERVER_URL_CONFIG_KEY); } private static boolean isOidcTenantEnabled() { diff --git a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/OidcMtlsDisabledInclusiveAuthTest.java b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/OidcMtlsDisabledInclusiveAuthTest.java new file mode 100644 index 0000000000000..94b913a6b3c5a --- /dev/null +++ b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/OidcMtlsDisabledInclusiveAuthTest.java @@ -0,0 +1,107 @@ +package io.quarkus.oidc.test; + +import static org.hamcrest.Matchers.is; + +import java.io.File; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.oidc.BearerTokenAuthentication; +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.test.QuarkusDevModeTest; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager; +import io.quarkus.vertx.http.runtime.security.annotation.MTLSAuthentication; +import io.restassured.RestAssured; +import io.restassured.specification.RequestSpecification; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import io.smallrye.certs.junit5.Certificates; + +/** + * This test ensures OIDC runs before mTLS authentication mechanism when inclusive authentication is not enabled. + */ +@QuarkusTestResource(KeycloakTestResourceLifecycleManager.class) +@Certificates(baseDir = "target/certs", certificates = @Certificate(name = "mtls-test", password = "secret", formats = { + Format.PKCS12, Format.PEM }, client = true)) +public class OidcMtlsDisabledInclusiveAuthTest { + + private static final String BASE_URL = "https://localhost:8443/mtls-bearer/"; + private static final String CONFIGURATION = """ + quarkus.tls.key-store.pem.0.cert=server.crt + quarkus.tls.key-store.pem.0.key=server.key + quarkus.tls.trust-store.pem.certs=ca.crt + quarkus.http.ssl.client-auth=REQUIRED + quarkus.http.insecure-requests=disabled + quarkus.oidc.auth-server-url=${keycloak.url}/realms/quarkus + quarkus.oidc.client-id=quarkus-service-app + quarkus.oidc.credentials.secret=secret + quarkus.http.auth.proactive=false + """; + + @RegisterExtension + static final QuarkusDevModeTest config = new QuarkusDevModeTest() + .withApplicationRoot((jar) -> jar + .addClasses(MtlsBearerResource.class) + .addAsResource(new StringAsset(CONFIGURATION), "application.properties") + .addAsResource(new File("target/certs/mtls-test.key"), "server.key") + .addAsResource(new File("target/certs/mtls-test.crt"), "server.crt") + .addAsResource(new File("target/certs/mtls-test-server-ca.crt"), "ca.crt")); + + @Test + public void testOidcHasHighestPriority() { + givenWithCerts().get(BASE_URL + "only-mtls").then().statusCode(200).body(is("CN=localhost")); + givenWithCerts().auth().oauth2(getAccessToken()).get(BASE_URL + "only-bearer").then().statusCode(200).body(is("alice")); + // this needs to be OIDC because when inclusive auth is disabled, OIDC has higher priority + givenWithCerts().auth().oauth2(getAccessToken()).get(BASE_URL + "both").then().statusCode(200).body(is("alice")); + // OIDC must run first and thus authentication fails over invalid credentials + givenWithCerts().auth().oauth2("invalid-token").get(BASE_URL + "both").then().statusCode(401); + // mTLS authentication mechanism still runs when OIDC doesn't produce the identity + givenWithCerts().get(BASE_URL + "both").then().statusCode(200).body(is("CN=localhost")); + } + + private static RequestSpecification givenWithCerts() { + return RestAssured.given() + .keyStore("target/certs/mtls-test-client-keystore.p12", "secret") + .trustStore("target/certs/mtls-test-client-truststore.p12", "secret"); + } + + private static String getAccessToken() { + return KeycloakTestResourceLifecycleManager.getAccessToken("alice"); + } + + @Path("mtls-bearer") + public static class MtlsBearerResource { + + @Inject + SecurityIdentity securityIdentity; + + @GET + @Authenticated + @Path("both") + public String both() { + return securityIdentity.getPrincipal().getName(); + } + + @GET + @MTLSAuthentication + @Path("only-mtls") + public String onlyMTLS() { + return securityIdentity.getPrincipal().getName(); + } + + @GET + @BearerTokenAuthentication + @Path("only-bearer") + public String onlyBearer() { + return securityIdentity.getPrincipal().getName(); + } + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfigurationMetadata.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfigurationMetadata.java index 9281aa23e226e..bcdde90ad1f74 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfigurationMetadata.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfigurationMetadata.java @@ -107,6 +107,10 @@ public String getEndSessionUri() { return endSessionUri; } + public String getRegistrationUri() { + return registrationUri; + } + public List getSupportedScopes() { return getStringList(SCOPES_SUPPORTED); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfigBuilder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfigBuilder.java index a189f2aa7fa6f..2600affd4c7fa 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfigBuilder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfigBuilder.java @@ -466,20 +466,11 @@ public OidcTenantConfigBuilder token(Token token) { } /** - * @param verifyAccessTokenWithUserInfo {@link Token#verifyAccessTokenWithUserInfo()} * @param principalClaim {@link Token#principalClaim()} * @return this builder */ - public OidcTenantConfigBuilder token(boolean verifyAccessTokenWithUserInfo, String principalClaim) { - return token().verifyAccessTokenWithUserInfo(verifyAccessTokenWithUserInfo).principalClaim(principalClaim).end(); - } - - /** - * @param verifyAccessTokenWithUserInfo {@link Token#verifyAccessTokenWithUserInfo()} - * @return this builder - */ - public OidcTenantConfigBuilder token(boolean verifyAccessTokenWithUserInfo) { - return token().verifyAccessTokenWithUserInfo(verifyAccessTokenWithUserInfo).end(); + public OidcTenantConfigBuilder token(String principalClaim) { + return token().principalClaim(principalClaim).end(); } /** diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java index b6d7a0d6d3cfe..32491900bcb36 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java @@ -513,7 +513,7 @@ private boolean isBackChannelLogoutPending(TenantConfigContext configContext, Se BackChannelLogoutTokenCache tokens = resolver.getBackChannelLogoutTokens() .get(configContext.oidcConfig().tenantId().get()); if (tokens != null) { - JsonObject idTokenJson = OidcUtils.decodeJwtContent(((JsonWebToken) (identity.getPrincipal())).getRawToken()); + JsonObject idTokenJson = OidcCommonUtils.decodeJwtContent(((JsonWebToken) (identity.getPrincipal())).getRawToken()); String logoutTokenKeyValue = idTokenJson .getString(configContext.oidcConfig().logout().backchannel().logoutTokenKey()); @@ -530,7 +530,7 @@ private boolean isBackChannelLogoutPendingAndValid(TenantConfigContext configCon BackChannelLogoutTokenCache tokens = resolver.getBackChannelLogoutTokens() .get(configContext.oidcConfig().tenantId().get()); if (tokens != null) { - JsonObject idTokenJson = OidcUtils.decodeJwtContent(((JsonWebToken) (identity.getPrincipal())).getRawToken()); + JsonObject idTokenJson = OidcCommonUtils.decodeJwtContent(((JsonWebToken) (identity.getPrincipal())).getRawToken()); String logoutTokenKeyValue = idTokenJson .getString(configContext.oidcConfig().logout().backchannel().logoutTokenKey()); @@ -572,7 +572,7 @@ private boolean isBackChannelLogoutPendingAndValid(TenantConfigContext configCon private boolean isFrontChannelLogoutValid(RoutingContext context, TenantConfigContext configContext, SecurityIdentity identity) { if (isEqualToRequestPath(configContext.oidcConfig().logout().frontchannel().path(), context, configContext)) { - JsonObject idTokenJson = OidcUtils.decodeJwtContent(((JsonWebToken) (identity.getPrincipal())).getRawToken()); + JsonObject idTokenJson = OidcCommonUtils.decodeJwtContent(((JsonWebToken) (identity.getPrincipal())).getRawToken()); String idTokenIss = idTokenJson.getString(Claims.iss.name()); List frontChannelIss = context.queryParam(Claims.iss.name()); @@ -975,7 +975,7 @@ private CodeAuthenticationStateBean getCodeAuthenticationBean(String[] parsedSta boolean pkceRequired = authentication.pkceRequired().orElse(false); if (!pkceRequired && !authentication.nonceRequired()) { - JsonObject json = new JsonObject(OidcUtils.base64UrlDecode(parsedStateCookieValue[1])); + JsonObject json = new JsonObject(OidcCommonUtils.base64UrlDecode(parsedStateCookieValue[1])); bean.setRestorePath(json.getString(OidcUtils.STATE_COOKIE_RESTORE_PATH)); return bean; } @@ -1048,7 +1048,7 @@ private Uni processSuccessfulAuthentication(RoutingContext context, @Override public Uni apply(Void t) { - JsonObject idTokenJson = OidcUtils.decodeJwtContent(idToken); + JsonObject idTokenJson = OidcCommonUtils.decodeJwtContent(idToken); if (!idTokenJson.containsKey("exp") || !idTokenJson.containsKey("iat")) { final String error = "ID Token is required to contain 'exp' and 'iat' claims"; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java index 717fa6b0cff89..673757cbd291d 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java @@ -24,6 +24,7 @@ import io.quarkus.oidc.TokenIntrospectionCache; import io.quarkus.oidc.UserInfo; import io.quarkus.oidc.UserInfoCache; +import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.oidc.runtime.OidcTenantConfig.Roles.Source; import io.quarkus.security.AuthenticationCompletionException; @@ -190,7 +191,8 @@ private Uni verifyPrimaryTokenUni(Map r if (requestData.get(NEW_AUTHENTICATION) == Boolean.TRUE) { // No need to verify it in this case as 'CodeAuthenticationMechanism' has just created it return Uni.createFrom() - .item(new TokenVerificationResult(OidcUtils.decodeJwtContent(request.getToken().getToken()), null)); + .item(new TokenVerificationResult(OidcCommonUtils.decodeJwtContent(request.getToken().getToken()), + null)); } else { return verifySelfSignedTokenUni(resolvedContext, request.getToken().getToken()); } @@ -286,7 +288,7 @@ private Uni createSecurityIdentityWithOidcServer(TokenVerifica // JSON token representation may be null not only if it is an opaque access token // but also if it is JWT and no JWK with a matching kid is available, asynchronous // JWK refresh has not finished yet, but the fallback introspection request has succeeded. - tokenJson = OidcUtils.decodeJwtContent(tokenCred.getToken()); + tokenJson = OidcCommonUtils.decodeJwtContent(tokenCred.getToken()); } if (tokenJson != null) { try { @@ -422,7 +424,7 @@ private static JsonObject getRolesJson(Map requestData, TenantCo // JSON token representation may be null not only if it is an opaque access token // but also if it is JWT and no JWK with a matching kid is available, asynchronous // JWK refresh has not finished yet, but the fallback introspection request has succeeded. - rolesJson = OidcUtils.decodeJwtContent((String) requestData.get(OidcConstants.ACCESS_TOKEN_VALUE)); + rolesJson = OidcCommonUtils.decodeJwtContent((String) requestData.get(OidcConstants.ACCESS_TOKEN_VALUE)); } } } @@ -589,7 +591,7 @@ private static Uni validateTokenWithoutOidcServer(TokenAuthent private Uni getUserInfoUni(Map requestData, TokenAuthenticationRequest request, TenantConfigContext resolvedContext) { if (isInternalIdToken(request) && OidcUtils.cacheUserInfoInIdToken(tenantResolver, resolvedContext.oidcConfig())) { - JsonObject userInfo = OidcUtils.decodeJwtContent(request.getToken().getToken()) + JsonObject userInfo = OidcCommonUtils.decodeJwtContent(request.getToken().getToken()) .getJsonObject(OidcUtils.USER_INFO_ATTRIBUTE); if (userInfo != null) { return Uni.createFrom().item(new UserInfo(userInfo.encode())); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java index a7f24b28a1740..c50748cf8e936 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java @@ -270,7 +270,7 @@ private TokenVerificationResult verifyJwtTokenInternal(String token, } throw ex; } - TokenVerificationResult result = new TokenVerificationResult(OidcUtils.decodeJwtContent(token), null); + TokenVerificationResult result = new TokenVerificationResult(OidcCommonUtils.decodeJwtContent(token), null); verifyTokenAge(result.localVerificationResult.getLong(Claims.iat.name())); return result; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java index 24395a65e3254..39a696bf8d27e 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java @@ -13,16 +13,17 @@ import io.quarkus.oidc.AuthorizationCodeTokens; import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcConfigurationMetadata; -import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.TokenIntrospection; import io.quarkus.oidc.common.OidcEndpoint; import io.quarkus.oidc.common.OidcRequestContextProperties; import io.quarkus.oidc.common.OidcRequestFilter; import io.quarkus.oidc.common.OidcRequestFilter.OidcRequestContext; import io.quarkus.oidc.common.OidcResponseFilter; +import io.quarkus.oidc.common.runtime.ClientAssertionProvider; import io.quarkus.oidc.common.runtime.OidcClientRedirectException; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; +import io.quarkus.oidc.common.runtime.config.OidcClientCommonConfig; import io.quarkus.oidc.common.runtime.config.OidcClientCommonConfig.Credentials.Secret.Method; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.groups.UniOnItem; @@ -41,8 +42,7 @@ public class OidcProviderClient implements Closeable { private static final String AUTHORIZATION_HEADER = String.valueOf(HttpHeaders.AUTHORIZATION); private static final String CONTENT_TYPE_HEADER = String.valueOf(HttpHeaders.CONTENT_TYPE); private static final String ACCEPT_HEADER = String.valueOf(HttpHeaders.ACCEPT); - private static final String APPLICATION_X_WWW_FORM_URLENCODED = String - .valueOf(HttpHeaders.APPLICATION_X_WWW_FORM_URLENCODED.toString()); + private static final String APPLICATION_X_WWW_FORM_URLENCODED = HttpHeaders.APPLICATION_X_WWW_FORM_URLENCODED.toString(); private static final String APPLICATION_JSON = "application/json"; private final WebClient client; @@ -52,6 +52,8 @@ public class OidcProviderClient implements Closeable { private final String clientSecretBasicAuthScheme; private final String introspectionBasicAuthScheme; private final Key clientJwtKey; + private final boolean jwtBearerAuthentication; + private final ClientAssertionProvider clientAssertionProvider; private final Map> requestFilters; private final Map> responseFilters; private final boolean clientSecretQueryAuthentication; @@ -67,13 +69,26 @@ public OidcProviderClient(WebClient client, this.metadata = metadata; this.oidcConfig = oidcConfig; this.clientSecretBasicAuthScheme = OidcCommonUtils.initClientSecretBasicAuth(oidcConfig); - this.clientJwtKey = OidcCommonUtils.initClientJwtKey(oidcConfig, true); + this.jwtBearerAuthentication = oidcConfig.credentials().jwt() + .source() == OidcClientCommonConfig.Credentials.Jwt.Source.BEARER; + this.clientAssertionProvider = this.jwtBearerAuthentication ? createClientAssertionProvider(vertx, oidcConfig) : null; + this.clientJwtKey = jwtBearerAuthentication ? null : OidcCommonUtils.initClientJwtKey(oidcConfig, true); this.introspectionBasicAuthScheme = initIntrospectionBasicAuthScheme(oidcConfig); this.requestFilters = requestFilters; this.responseFilters = responseFilters; this.clientSecretQueryAuthentication = oidcConfig.credentials().clientSecret().method().orElse(null) == Method.QUERY; } + private static ClientAssertionProvider createClientAssertionProvider(Vertx vertx, OidcTenantConfig oidcConfig) { + var clientAssertionProvider = new ClientAssertionProvider(vertx, + oidcConfig.credentials().jwt().tokenPath().get()); + if (clientAssertionProvider.getClientAssertion() == null) { + throw new OIDCException("Cannot find a valid JWT bearer token at path: " + + oidcConfig.credentials().jwt().tokenPath().get()); + } + return clientAssertionProvider; + } + private static String initIntrospectionBasicAuthScheme(OidcTenantConfig oidcConfig) { if (oidcConfig.introspectionCredentials().name().isPresent() && oidcConfig.introspectionCredentials().secret().isPresent()) { @@ -111,7 +126,7 @@ private Uni doGetJsonWebKeySet(OidcRequestContextProperties reque return OidcCommonUtils .sendRequest(vertx, filterHttpRequest(requestProps, OidcEndpoint.Type.JWKS, request, null), - oidcConfig.useBlockingDnsLookup) + oidcConfig.useBlockingDnsLookup()) .onItem() .transform(resp -> getJsonWebKeySet(requestProps, resp)); } @@ -143,7 +158,7 @@ private Uni doGetUserInfo(OidcRequestContextProperties request .sendRequest(vertx, filterHttpRequest(requestProps, OidcEndpoint.Type.USERINFO, request, null) .putHeader(AUTHORIZATION_HEADER, OidcConstants.BEARER_SCHEME + " " + token), - oidcConfig.useBlockingDnsLookup) + oidcConfig.useBlockingDnsLookup()) .onItem().transform(resp -> getUserInfo(requestProps, resp)); } @@ -206,6 +221,15 @@ private UniOnItem> getHttpResponse(OidcRequestContextProper } } else if (clientSecretBasicAuthScheme != null) { request.putHeader(AUTHORIZATION_HEADER, clientSecretBasicAuthScheme); + } else if (jwtBearerAuthentication) { + final String clientAssertion = clientAssertionProvider.getClientAssertion(); + if (clientAssertion == null) { + throw new OIDCException(String.format( + "Cannot get token for tenant '%s' because a JWT bearer client_assertion is not available", + oidcConfig.tenantId().get())); + } + formBody.add(OidcConstants.CLIENT_ASSERTION, clientAssertion); + formBody.add(OidcConstants.CLIENT_ASSERTION_TYPE, OidcConstants.JWT_BEARER_CLIENT_ASSERTION_TYPE); } else if (clientJwtKey != null) { String jwt = OidcCommonUtils.signJwtWithKey(oidcConfig, metadata.getTokenUri(), clientJwtKey); if (OidcCommonUtils.isClientSecretPostJwtAuthRequired(oidcConfig.credentials())) { @@ -251,7 +275,7 @@ private UniOnItem> getHttpResponse(OidcRequestContextProper private AuthorizationCodeTokens getAuthorizationCodeTokens(OidcRequestContextProperties requestProps, HttpResponse resp) { - JsonObject json = getJsonObject(requestProps, metadata.getAuthorizationUri(), resp, OidcEndpoint.Type.TOKEN); + JsonObject json = getJsonObject(requestProps, metadata.getTokenUri(), resp, OidcEndpoint.Type.TOKEN); final String idToken = json.getString(OidcConstants.ID_TOKEN_VALUE); final String accessToken = json.getString(OidcConstants.ACCESS_TOKEN_VALUE); final String refreshToken = json.getString(OidcConstants.REFRESH_TOKEN_VALUE); @@ -318,6 +342,9 @@ private static OIDCException responseException(String requestUri, HttpResponse T getAttribute(SecurityIdentity identity, String name) { public static boolean isJwtTokenExpired(String token) { if (!isOpaqueToken(token)) { - JsonObject claims = decodeJwtContent(token); + JsonObject claims = OidcCommonUtils.decodeJwtContent(token); Long expiresAt = getJwtExpiresAtClaim(claims); if (expiresAt == null) { return false; @@ -830,7 +781,7 @@ public static boolean isJwtTokenExpired(String token) { return false; } - private static Long getJwtExpiresAtClaim(JsonObject claims) { + static Long getJwtExpiresAtClaim(JsonObject claims) { if (claims == null || !claims.containsKey(Claims.exp.name())) { return null; } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/StaticTenantResolver.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/StaticTenantResolver.java index 3a968768e54bd..1cc0128faf13b 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/StaticTenantResolver.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/StaticTenantResolver.java @@ -16,6 +16,7 @@ import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.TenantResolver; +import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.vertx.http.runtime.security.ImmutablePathMatcher; import io.smallrye.mutiny.Uni; import io.vertx.ext.web.RoutingContext; @@ -220,7 +221,7 @@ private boolean tryToInitialize(TenantConfigContext context) { private static String getTenantId(RoutingContext context, TenantConfigContext tenantContext) { final String token = OidcUtils.extractBearerToken(context, tenantContext.oidcConfig()); if (token != null && !OidcUtils.isOpaqueToken(token)) { - final var tokenJson = OidcUtils.decodeJwtContent(token); + final var tokenJson = OidcCommonUtils.decodeJwtContent(token); if (tokenJson != null) { final String iss = tokenJson.getString(Claims.iss.name()); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java index 8e1e6348e94e4..8912c22ad4065 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java @@ -34,7 +34,7 @@ private static OidcTenantConfig slack() { return OidcTenantConfig .authServerUrl("https://slack.com") .applicationType(WEB_APP) - .token().principalClaim("name").end() + .token("name") .authentication() .forceRedirectHttpsScheme() .scopes("profile", "email") @@ -64,7 +64,7 @@ private static OidcTenantConfig github() { .authorizationPath("authorize") .tokenPath("access_token") .userInfoPath("https://api.github.com/user") - .token(true, "name") + .token().verifyAccessTokenWithUserInfo(true).principalClaim("name").end() .authentication(authBuilder.build()) .build(); } @@ -93,7 +93,7 @@ private static OidcTenantConfig google() { .authServerUrl("https://accounts.google.com") .applicationType(WEB_APP) .authentication().scopes("openid", "email", "profile").end() - .token(true, "name") + .token().verifyAccessTokenWithUserInfo(true).principalClaim("name").end() .build(); } @@ -171,7 +171,7 @@ private static OidcTenantConfig spotify() { .authorizationPath("authorize") .tokenPath("api/token") .userInfoPath("https://api.spotify.com/v1/me") - .token(true, "display_name") + .token().verifyAccessTokenWithUserInfo(true).principalClaim("display_name").end() .authentication(authentication) .build(); } @@ -183,7 +183,7 @@ private static OidcTenantConfig strava() { .discoveryEnabled(false) .authorizationPath("authorize") .tokenPath("token") - .token(true) + .token().verifyAccessTokenWithUserInfo(true).end() .userInfoPath("https://www.strava.com/api/v3/athlete"); builder.authentication() @@ -218,7 +218,7 @@ private static OidcTenantConfig discord() { .authorizationPath("authorize") .tokenPath("token") .jwksPath("keys") - .token(true) + .token().verifyAccessTokenWithUserInfo(true).end() .authentication().scopes("identify", "email").idTokenRequired(false).end() .userInfoPath("https://discord.com/api/users/@me") .build(); diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigBuilderTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigBuilderTest.java index b1b3383f589e2..a3bb7b8b92798 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigBuilderTest.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigBuilderTest.java @@ -222,6 +222,7 @@ public void testDefaultValues() { assertTrue(jwt.signatureAlgorithm().isEmpty()); assertEquals(10, jwt.lifespan()); assertFalse(jwt.assertion()); + assertTrue(jwt.tokenPath().isEmpty()); // OidcCommonConfig methods assertTrue(config.authServerUrl().isEmpty()); @@ -352,6 +353,7 @@ public void testSetEveryProperty() { .end() .jwt() .source(Source.BEARER) + .tokenPath(Path.of("my-super-bearer-path")) .secretProvider() .keyringName("jwt-keyring-name-yep") .key("jwt-key-yep") @@ -618,6 +620,7 @@ public void testSetEveryProperty() { assertEquals("jwt-token-key-id-yep", jwt.tokenKeyId().orElse(null)); assertEquals("jwt-issuer", jwt.issuer().orElse(null)); assertEquals("jwt-subject", jwt.subject().orElse(null)); + assertEquals("my-super-bearer-path", jwt.tokenPath().orElseThrow().toString()); var claims = jwt.claims(); assertNotNull(claims); assertEquals(2, claims.size()); @@ -761,6 +764,7 @@ public void testCopyOidcClientCommonConfigProperties() { .end() .jwt() .source(Source.BEARER) + .tokenPath(Path.of("jwt-bearer-token-path-test-1")) .secretProvider() .keyringName("jwt-keyring-name-yep") .key("jwt-key-yep") @@ -895,6 +899,7 @@ public void testCopyOidcClientCommonConfigProperties() { assertEquals("jwt-token-key-id-yep", jwt.tokenKeyId().orElse(null)); assertEquals("jwt-issuer-CHANGED", jwt.issuer().orElse(null)); assertEquals("jwt-subject", jwt.subject().orElse(null)); + assertEquals("jwt-bearer-token-path-test-1", jwt.tokenPath().orElseThrow().toString()); claims = jwt.claims(); assertNotNull(claims); assertEquals(3, claims.size()); @@ -1084,7 +1089,7 @@ public void testTokenBuilder() { .requiredClaims(Map.of("III", "IV")) .audience("extra"); var config2 = second.end() - .token(false, "prince") + .token().verifyAccessTokenWithUserInfo(false).principalClaim("prince").end() .build(); var builtSecond = config2.token(); assertFalse(builtSecond.verifyAccessTokenWithUserInfo().orElseThrow()); @@ -1102,7 +1107,7 @@ public void testTokenBuilder() { assertTrue(builtSecond.audience().orElseThrow().contains("extra")); assertEquals("prince", builtSecond.principalClaim().orElse(null)); - var config3 = OidcTenantConfig.builder(config2).token(true).build(); + var config3 = OidcTenantConfig.builder(config2).token().verifyAccessTokenWithUserInfo().end().build(); assertTrue(config3.token().verifyAccessTokenWithUserInfo().orElseThrow()); assertEquals("haha", config3.tenantId().orElse(null)); diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigImpl.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigImpl.java index b71efbcb8f78c..73bc8ddf0671b 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigImpl.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigImpl.java @@ -195,7 +195,8 @@ enum ConfigMappingMethods { INTROSPECTION_CREDENTIALS_NAME, INTROSPECTION_CREDENTIALS_SECRET, INTROSPECTION_CREDENTIALS_INCLUDE_CLIENT_ID, - TENANT_ID + TENANT_ID, + JWT_BEARER_TOKEN_PATH } final Map invocationsRecorder = new EnumMap<>(ConfigMappingMethods.class); @@ -949,6 +950,12 @@ public Source source() { return Source.BEARER; } + @Override + public Optional tokenPath() { + invocationsRecorder.put(ConfigMappingMethods.JWT_BEARER_TOKEN_PATH, true); + return Optional.empty(); + } + @Override public Optional secret() { invocationsRecorder.put(ConfigMappingMethods.CREDENTIALS_JWT_SECRET, true); diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java index 15f03705d2936..abcf91f4150ee 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java @@ -25,6 +25,7 @@ import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.smallrye.jwt.build.Jwt; import io.vertx.core.http.Cookie; import io.vertx.core.http.impl.CookieImpl; @@ -245,9 +246,9 @@ public void testTokenIsOpaque() throws Exception { @Test public void testDecodeOpaqueTokenAsJwt() throws Exception { - assertNull(OidcUtils.decodeJwtContent("123")); - assertNull(OidcUtils.decodeJwtContent("1.23")); - assertNull(OidcUtils.decodeJwtContent("1.2.3")); + assertNull(OidcCommonUtils.decodeJwtContent("123")); + assertNull(OidcCommonUtils.decodeJwtContent("1.23")); + assertNull(OidcCommonUtils.decodeJwtContent("1.2.3")); } @Test @@ -256,8 +257,8 @@ public void testDecodeJwt() throws Exception { .getBytes(StandardCharsets.UTF_8); SecretKey key = new SecretKeySpec(keyBytes, 0, keyBytes.length, "HMACSHA256"); String jwt = Jwt.claims().sign(key); - assertNull(OidcUtils.decodeJwtContent(jwt + ".4")); - JsonObject json = OidcUtils.decodeJwtContent(jwt); + assertNull(OidcCommonUtils.decodeJwtContent(jwt + ".4")); + JsonObject json = OidcCommonUtils.decodeJwtContent(jwt); assertTrue(json.containsKey("iat")); assertTrue(json.containsKey("exp")); assertTrue(json.containsKey("jti")); diff --git a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/exporter/otlp/OtlpExporterProcessor.java b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/exporter/otlp/OtlpExporterProcessor.java index 6fc49278641f0..09a6989f74d61 100644 --- a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/exporter/otlp/OtlpExporterProcessor.java +++ b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/exporter/otlp/OtlpExporterProcessor.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.function.BooleanSupplier; +import java.util.logging.Level; import jakarta.enterprise.inject.Instance; import jakarta.inject.Singleton; @@ -21,6 +22,7 @@ import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.deployment.annotations.*; import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.LogCategoryBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigBuilderBuildItem; import io.quarkus.opentelemetry.runtime.config.build.OTelBuildConfig; import io.quarkus.opentelemetry.runtime.config.build.exporter.OtlpExporterBuildConfig; @@ -75,6 +77,13 @@ public boolean getAsBoolean() { } } + @BuildStep + void logging(BuildProducer log) { + // Reduce the log level of the exporters because it's too much, and we do log important things ourselves. + log.produce(new LogCategoryBuildItem("io.opentelemetry.exporter.internal.grpc.GrpcExporter", Level.OFF)); + log.produce(new LogCategoryBuildItem("io.opentelemetry.exporter.internal.http.HttpExporter", Level.OFF)); + } + @BuildStep void config(BuildProducer runTimeConfigBuilderProducer) { runTimeConfigBuilderProducer.produce(new RunTimeConfigBuilderBuildItem(OtlpExporterConfigBuilder.class)); diff --git a/extensions/quartz/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/quartz/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 0f47b14af6efc..7cc90a39328b3 100644 --- a/extensions/quartz/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/quartz/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -10,6 +10,6 @@ metadata: guide: "https://quarkus.io/guides/quartz" categories: - "miscellaneous" - status: "preview" + status: "stable" config: - - "quarkus.quartz." \ No newline at end of file + - "quarkus.quartz." diff --git a/extensions/redis-client/deployment/src/test/java/io/quarkus/redis/deployment/client/datasource/QuarkusObjectMapperTest.java b/extensions/redis-client/deployment/src/test/java/io/quarkus/redis/deployment/client/datasource/QuarkusObjectMapperTest.java new file mode 100644 index 0000000000000..a13ded00f6789 --- /dev/null +++ b/extensions/redis-client/deployment/src/test/java/io/quarkus/redis/deployment/client/datasource/QuarkusObjectMapperTest.java @@ -0,0 +1,134 @@ +package io.quarkus.redis.deployment.client.datasource; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +import io.quarkus.jackson.ObjectMapperCustomizer; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.redis.datasource.hash.HashCommands; +import io.quarkus.redis.deployment.client.RedisTestResource; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.QuarkusTestResource; + +@QuarkusTestResource(RedisTestResource.class) +public class QuarkusObjectMapperTest { + + @RegisterExtension + static final QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class).addClass(CustomCodecTest.Jedi.class).addClass( + CustomCodecTest.Sith.class) + .addClass(CustomCodecTest.CustomJediCodec.class).addClass(CustomCodecTest.CustomSithCodec.class)) + .overrideConfigKey("quarkus.redis.hosts", "${quarkus.redis.tr}"); + + @Inject + RedisDataSource ds; + + @Test + public void test() { + String key = UUID.randomUUID().toString(); + HashCommands> h = ds.hash(new TypeReference<>() { + + }); + h.hset(key, "test", List.of(new Person("foo", 100))); + String stringRetrieved = ds.hash(String.class).hget(key, "test"); + assertThat(stringRetrieved).isEqualTo("[{\"nAmE\":\"foo\",\"aGe\":100}]"); + List peopleRetrieved = h.hget(key, "test"); + assertThat(peopleRetrieved).singleElement().satisfies(p -> { + assertThat(p.getName()).isEqualTo("foo"); + assertThat(p.getAge()).isEqualTo(100); + }); + } + + // without a custom module, this could not be deserialized as there are 2 constructors + public static class Person { + private final String name; + private final int age; + + public Person(String name, int age) { + this.name = name; + this.age = age; + } + + @SuppressWarnings("unused") + public Person(String name) { + this.name = name; + this.age = 0; + } + + public String getName() { + return name; + } + + public int getAge() { + return age; + } + } + + @Singleton + public static class PersonCustomizer implements ObjectMapperCustomizer { + + @Override + public void customize(ObjectMapper objectMapper) { + SimpleModule module = new SimpleModule(); + module.addDeserializer(Person.class, new PersonDeserializer()); + module.addSerializer(Person.class, new PersonSerializer()); + objectMapper.registerModule(module); + } + } + + public static class PersonSerializer extends StdSerializer { + + protected PersonSerializer() { + super(Person.class); + } + + @Override + public void serialize(Person person, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) + throws IOException { + jsonGenerator.writeStartObject(); + jsonGenerator.writeStringField("nAmE", person.getName()); + jsonGenerator.writeNumberField("aGe", person.getAge()); + jsonGenerator.writeEndObject(); + } + } + + public static class PersonDeserializer extends StdDeserializer { + + protected PersonDeserializer() { + super(Person.class); + } + + @Override + public Person deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) + throws IOException { + JsonNode node = jsonParser.getCodec().readTree(jsonParser); + String name = node.get("nAmE").asText(); + int age = (Integer) node.get("aGe").numberValue(); + + return new Person(name, age); + } + } +} diff --git a/extensions/redis-client/runtime/src/main/java/io/quarkus/redis/datasource/codecs/Codecs.java b/extensions/redis-client/runtime/src/main/java/io/quarkus/redis/datasource/codecs/Codecs.java index 765caeb025e88..5022b7dbd67fc 100644 --- a/extensions/redis-client/runtime/src/main/java/io/quarkus/redis/datasource/codecs/Codecs.java +++ b/extensions/redis-client/runtime/src/main/java/io/quarkus/redis/datasource/codecs/Codecs.java @@ -11,9 +11,9 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.vertx.runtime.jackson.QuarkusJacksonJsonCodec; import io.vertx.core.buffer.Buffer; import io.vertx.core.json.Json; -import io.vertx.core.json.jackson.DatabindCodec; public class Codecs { @@ -61,7 +61,7 @@ public Type getType() { }; this.clazz = null; } - this.mapper = DatabindCodec.mapper(); + this.mapper = QuarkusJacksonJsonCodec.mapper(); } @Override diff --git a/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/AbstractRestClientConfigBuilder.java b/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/AbstractRestClientConfigBuilder.java index a4e2e0f7bcdbc..7ed5887381a76 100644 --- a/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/AbstractRestClientConfigBuilder.java +++ b/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/AbstractRestClientConfigBuilder.java @@ -2,11 +2,14 @@ import static io.smallrye.config.ConfigValue.CONFIG_SOURCE_COMPARATOR; +import java.util.ArrayList; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.OptionalInt; import java.util.function.Function; +import java.util.function.Supplier; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; @@ -17,7 +20,6 @@ import io.smallrye.config.ConfigValue; import io.smallrye.config.FallbackConfigSourceInterceptor; import io.smallrye.config.Priorities; -import io.smallrye.config.RelocateConfigSourceInterceptor; import io.smallrye.config.SmallRyeConfigBuilder; /** @@ -70,49 +72,66 @@ public SmallRyeConfigBuilder configBuilder(final SmallRyeConfigBuilder builder) Map quarkusFallbacks = new HashMap<>(); Map microProfileFallbacks = new HashMap<>(); - // relocates [All Combinations] -> quarkus.rest-client."FQN".* - Map relocates = new HashMap<>(); + Map> relocates = new HashMap<>(); + for (RegisteredRestClient restClient : restClients) { if (runtime) { RestClientsConfig.RestClientKeysProvider.KEYS.add(restClient.getFullName()); } - // FQN -> Simple Name String quotedFullName = "\"" + restClient.getFullName() + "\""; - quarkusFallbacks.put(quotedFullName, restClient.getSimpleName()); - relocates.put(restClient.getSimpleName(), quotedFullName); - // Simple Name -> Quoted Simple Name String quotedSimpleName = "\"" + restClient.getSimpleName() + "\""; - quarkusFallbacks.put(restClient.getSimpleName(), quotedSimpleName); - relocates.put(quotedSimpleName, quotedFullName); - String configKey = restClient.getConfigKey(); + + // relocates [All Combinations] -> quarkus.rest-client."FQN".* + List fullNameRelocates = new ArrayList<>(); + relocates.put(quotedFullName, fullNameRelocates); + if (configKey != null && !restClient.isConfigKeyEqualsNames()) { String quotedConfigKey = "\"" + configKey + "\""; if (!quotedConfigKey.equals(quotedFullName) && !quotedConfigKey.equals(quotedSimpleName)) { if (restClient.isConfigKeyComposed()) { - // Quoted Simple Name -> Quoted Config Key - quarkusFallbacks.put(quotedSimpleName, quotedConfigKey); - relocates.put(quotedConfigKey, quotedFullName); + // FQN -> Quoted Config Key -> Quoted Simple Name -> Simple Name + quarkusFallbacks.put(quotedFullName, quotedConfigKey); + quarkusFallbacks.put(quotedConfigKey, restClient.getSimpleName()); + quarkusFallbacks.put(restClient.getSimpleName(), quotedSimpleName); + fullNameRelocates.add(quotedConfigKey); + fullNameRelocates.add(restClient.getSimpleName()); + fullNameRelocates.add(quotedSimpleName); } else { - // Quoted Simple Name -> Config Key - quarkusFallbacks.put(quotedSimpleName, configKey); - relocates.put(configKey, quotedFullName); - // Config Key -> Quoted Config Key + // FQN -> Config Key -> Quoted Config Key -> Quoted Simple Name -> Simple Name + quarkusFallbacks.put(quotedFullName, configKey); quarkusFallbacks.put(configKey, quotedConfigKey); - relocates.put(quotedConfigKey, quotedFullName); + quarkusFallbacks.put(quotedConfigKey, restClient.getSimpleName()); + quarkusFallbacks.put(restClient.getSimpleName(), quotedSimpleName); + fullNameRelocates.add(configKey); + fullNameRelocates.add(quotedConfigKey); + fullNameRelocates.add(restClient.getSimpleName()); + fullNameRelocates.add(quotedSimpleName); } + } else { + // FQN -> Quoted Simple Name -> Simple Name + quarkusFallbacks.put(quotedFullName, restClient.getSimpleName()); + quarkusFallbacks.put(restClient.getSimpleName(), quotedSimpleName); + fullNameRelocates.add(restClient.getSimpleName()); + fullNameRelocates.add(quotedSimpleName); } + } else { + // FQN -> Quoted Simple Name -> Simple Name + quarkusFallbacks.put(quotedFullName, restClient.getSimpleName()); + quarkusFallbacks.put(restClient.getSimpleName(), quotedSimpleName); + fullNameRelocates.add(restClient.getSimpleName()); + fullNameRelocates.add(quotedSimpleName); } // FQN -> FQN/mp-rest String mpRestFullName = restClient.getFullName() + "/mp-rest/"; microProfileFallbacks.put(quotedFullName, mpRestFullName); - relocates.put(mpRestFullName, quotedFullName); + fullNameRelocates.add(mpRestFullName); if (configKey != null && !configKey.equals(restClient.getFullName())) { String mpConfigKey = configKey + "/mp-rest/"; microProfileFallbacks.put(mpRestFullName, mpConfigKey); - relocates.put(mpConfigKey, quotedFullName); + fullNameRelocates.add(mpConfigKey); } } @@ -145,12 +164,7 @@ public OptionalInt getPriority() { builder.withInterceptorFactories(new ConfigSourceInterceptorFactory() { @Override public ConfigSourceInterceptor getInterceptor(final ConfigSourceInterceptorContext context) { - return new RelocateConfigSourceInterceptor(new Relocates(relocates)) { - @Override - public ConfigValue getValue(final ConfigSourceInterceptorContext context, final String name) { - return context.proceed(name); - } - }; + return new Relocates(relocates); } }); return builder; @@ -298,35 +312,105 @@ public String apply(final String name) { /** * Relocates every possible name (Quarkus and MP) to - * quarkus.rest-client."[FQN of the REST Interface]".* + * quarkus.rest-client."[FQN of the REST Interface]".*. The same configKey can relocate + * to many quarkus.rest-client."[FQN of the REST Interface]".*. */ - private record Relocates(Map names) implements Function { + private static class Relocates implements ConfigSourceInterceptor { + private final Map> relocates; + + Relocates(final Map> relocates) { + this.relocates = new HashMap<>(); + // Inverts the Map to make it easier to search + for (Map.Entry> entry : relocates.entrySet()) { + for (String from : entry.getValue()) { + this.relocates.putIfAbsent(from, new ArrayList<>()); + this.relocates.get(from).add(entry.getKey()); + } + } + } + @Override - public String apply(final String name) { - int indexOfRestClient = indexOfRestClient(name); - if (indexOfRestClient != -1) { - for (Map.Entry entry : names.entrySet()) { - String original = entry.getKey(); - String target = entry.getValue(); - int endOfConfigKey = indexOfRestClient + original.length(); - if (name.regionMatches(indexOfRestClient, original, 0, original.length())) { - if (name.length() > endOfConfigKey && name.charAt(endOfConfigKey) == '.') { - return REST_CLIENT_PREFIX + target + name.substring(endOfConfigKey); + public ConfigValue getValue(final ConfigSourceInterceptorContext context, final String name) { + return context.proceed(name); + } + + @Override + public Iterator iterateNames(final ConfigSourceInterceptorContext context) { + List relocatedNames = new ArrayList<>(relocates.size()); + List>> iterators = new ArrayList<>(); + iterators.add(new Supplier>() { + @Override + public Iterator get() { + Iterator names = context.iterateNames(); + + return new Iterator<>() { + @Override + public boolean hasNext() { + return names.hasNext(); } - } + + @Override + public String next() { + String name = names.next(); + int indexOfRestClient = indexOfRestClient(name); + if (indexOfRestClient != -1) { + for (Map.Entry> entry : relocates.entrySet()) { + String original = entry.getKey(); + int endOfConfigKey = indexOfRestClient + original.length(); + if (name.regionMatches(indexOfRestClient, original, 0, original.length())) { + if (name.length() > endOfConfigKey && name.charAt(endOfConfigKey) == '.') { + for (String relocatedName : entry.getValue()) { + relocatedNames.add( + REST_CLIENT_PREFIX + relocatedName + name.substring(endOfConfigKey)); + } + return name; + } + } + } + } + int slash = name.indexOf("/"); + if (slash != -1) { + if (name.regionMatches(slash + 1, "mp-rest/", 0, 8)) { + String property = name.substring(slash + 9); + if (MICROPROFILE_NAMES.containsKey(property)) { + relocatedNames.add(REST_CLIENT_PREFIX + "\"" + name.substring(0, slash) + "\"." + + MICROPROFILE_NAMES.getOrDefault(property, property)); + } + return name; + } + } + return name; + } + }; } - } - int slash = name.indexOf("/"); - if (slash != -1) { - if (name.regionMatches(slash + 1, "mp-rest/", 0, 8)) { - String property = name.substring(slash + 9); - if (MICROPROFILE_NAMES.containsKey(property)) { - return REST_CLIENT_PREFIX + "\"" + name.substring(0, slash) + "\"." - + MICROPROFILE_NAMES.getOrDefault(property, property); + }); + iterators.add(new Supplier>() { + @Override + public Iterator get() { + return relocatedNames.iterator(); + } + }); + Iterator>> iterator = iterators.iterator(); + return new Iterator<>() { + Iterator names = iterator.next().get(); + + @Override + public boolean hasNext() { + if (names.hasNext()) { + return true; + } else { + if (iterator.hasNext()) { + names = iterator.next().get(); + } + return names.hasNext(); } } - } - return name; + + @Override + public String next() { + return names.next(); + } + }; } } diff --git a/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/RestClientsConfig.java b/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/RestClientsConfig.java index 1c3f23709530d..c57d27ee67ed5 100644 --- a/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/RestClientsConfig.java +++ b/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/RestClientsConfig.java @@ -177,8 +177,7 @@ public interface RestClientsConfig { Optional followRedirects(); /** - * Map where keys are fully-qualified provider classnames to include in the client, and values are their integer - * priorities. The equivalent of the `@RegisterProvider` annotation. + * Fully-qualified provider classnames to include in the client. The equivalent of the `@RegisterProvider` annotation. *

* Can be overwritten by client-specific settings. */ diff --git a/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/RestClientConfigTest.java b/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/RestClientConfigTest.java index 2a8c2621726c5..7b73ed5ae3942 100644 --- a/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/RestClientConfigTest.java +++ b/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/RestClientConfigTest.java @@ -13,6 +13,9 @@ import org.junit.jupiter.api.Test; import io.quarkus.restclient.config.RestClientsConfig.RestClientConfig; +import io.quarkus.restclient.config.key.SharedOneConfigKeyRestClient; +import io.quarkus.restclient.config.key.SharedThreeConfigKeyRestClient; +import io.quarkus.restclient.config.key.SharedTwoConfigKeyRestClient; import io.quarkus.runtime.configuration.ConfigUtils; import io.smallrye.config.SmallRyeConfig; import io.smallrye.config.SmallRyeConfigBuilder; @@ -233,6 +236,81 @@ public List getRestClients() { assertThat(restClientConfig.uri().get()).isEqualTo("http://localhost:8082"); } + @Test + void restClientDuplicateSimpleName() { + SmallRyeConfig config = ConfigUtils.emptyConfigBuilder() + .withMapping(RestClientsConfig.class) + .withCustomizers(new SmallRyeConfigBuilderCustomizer() { + @Override + public void configBuilder(final SmallRyeConfigBuilder builder) { + new AbstractRestClientConfigBuilder() { + @Override + public List getRestClients() { + return List.of( + new RegisteredRestClient( + io.quarkus.restclient.config.simple.one.SimpleNameRestClient.class, "one"), + new RegisteredRestClient( + io.quarkus.restclient.config.simple.two.SimpleNameRestClient.class, "two")); + } + }.configBuilder(builder); + } + }) + .build(); + assertNotNull(config); + + RestClientsConfig restClientsConfig = config.getConfigMapping(RestClientsConfig.class); + assertEquals(2, restClientsConfig.clients().size()); + assertThat(restClientsConfig.getClient(io.quarkus.restclient.config.simple.one.SimpleNameRestClient.class).uri().get()) + .isEqualTo("http://localhost:8081"); + assertThat(restClientsConfig.getClient(io.quarkus.restclient.config.simple.two.SimpleNameRestClient.class).uri().get()) + .isEqualTo("http://localhost:8082"); + } + + @Test + void restClientSharedConfigKey() { + SmallRyeConfig config = ConfigUtils.emptyConfigBuilder() + .withMapping(RestClientsConfig.class) + .withCustomizers(new SmallRyeConfigBuilderCustomizer() { + @Override + public void configBuilder(final SmallRyeConfigBuilder builder) { + new AbstractRestClientConfigBuilder() { + @Override + public List getRestClients() { + return List.of( + new RegisteredRestClient(SharedOneConfigKeyRestClient.class, "shared"), + new RegisteredRestClient(SharedTwoConfigKeyRestClient.class, "shared"), + new RegisteredRestClient(SharedThreeConfigKeyRestClient.class, "shared")); + } + }.configBuilder(builder); + } + }) + .build(); + assertNotNull(config); + + RestClientsConfig restClientsConfig = config.getConfigMapping(RestClientsConfig.class); + assertEquals(3, restClientsConfig.clients().size()); + + RestClientConfig restClientConfigOne = restClientsConfig.getClient(SharedOneConfigKeyRestClient.class); + assertThat(restClientConfigOne.uri().get()).isEqualTo("http://localhost:8081"); + assertEquals(2, restClientConfigOne.headers().size()); + assertEquals("one", restClientConfigOne.headers().get("two")); + assertEquals("two", restClientConfigOne.headers().get("one")); + + RestClientConfig restClientConfigTwo = restClientsConfig + .getClient(SharedTwoConfigKeyRestClient.class); + assertThat(restClientConfigTwo.uri().get()).isEqualTo("http://localhost:8082"); + assertEquals(2, restClientConfigTwo.headers().size()); + assertEquals("one", restClientConfigTwo.headers().get("two")); + assertEquals("two", restClientConfigTwo.headers().get("one")); + + RestClientConfig restClientConfigThree = restClientsConfig + .getClient(SharedThreeConfigKeyRestClient.class); + assertThat(restClientConfigThree.uri().get()).isEqualTo("http://localhost:8083"); + assertEquals(2, restClientConfigThree.headers().size()); + assertEquals("one", restClientConfigThree.headers().get("two")); + assertEquals("two", restClientConfigThree.headers().get("one")); + } + @Test void buildTimeConfig() { SmallRyeConfig config = ConfigUtils.emptyConfigBuilder() diff --git a/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/key/SharedOneConfigKeyRestClient.java b/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/key/SharedOneConfigKeyRestClient.java new file mode 100644 index 0000000000000..53350e34da0b5 --- /dev/null +++ b/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/key/SharedOneConfigKeyRestClient.java @@ -0,0 +1,7 @@ +package io.quarkus.restclient.config.key; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@RegisterRestClient(configKey = "shared") +public interface SharedOneConfigKeyRestClient { +} diff --git a/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/key/SharedThreeConfigKeyRestClient.java b/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/key/SharedThreeConfigKeyRestClient.java new file mode 100644 index 0000000000000..cc87e958a0b23 --- /dev/null +++ b/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/key/SharedThreeConfigKeyRestClient.java @@ -0,0 +1,7 @@ +package io.quarkus.restclient.config.key; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@RegisterRestClient(configKey = "shared") +public interface SharedThreeConfigKeyRestClient { +} diff --git a/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/key/SharedTwoConfigKeyRestClient.java b/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/key/SharedTwoConfigKeyRestClient.java new file mode 100644 index 0000000000000..ec04debb91ab7 --- /dev/null +++ b/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/key/SharedTwoConfigKeyRestClient.java @@ -0,0 +1,7 @@ +package io.quarkus.restclient.config.key; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@RegisterRestClient(configKey = "shared") +public interface SharedTwoConfigKeyRestClient { +} diff --git a/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/simple/one/SimpleNameRestClient.java b/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/simple/one/SimpleNameRestClient.java new file mode 100644 index 0000000000000..695b709324a0c --- /dev/null +++ b/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/simple/one/SimpleNameRestClient.java @@ -0,0 +1,7 @@ +package io.quarkus.restclient.config.simple.one; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@RegisterRestClient(configKey = "one") +public interface SimpleNameRestClient { +} diff --git a/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/simple/two/SimpleNameRestClient.java b/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/simple/two/SimpleNameRestClient.java new file mode 100644 index 0000000000000..586746a2a82c5 --- /dev/null +++ b/extensions/resteasy-classic/rest-client-config/runtime/src/test/java/io/quarkus/restclient/config/simple/two/SimpleNameRestClient.java @@ -0,0 +1,7 @@ +package io.quarkus.restclient.config.simple.two; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@RegisterRestClient(configKey = "two") +public interface SimpleNameRestClient { +} diff --git a/extensions/resteasy-classic/rest-client-config/runtime/src/test/resources/application.properties b/extensions/resteasy-classic/rest-client-config/runtime/src/test/resources/application.properties index fb083bea6f36b..8e4560a7ddb65 100644 --- a/extensions/resteasy-classic/rest-client-config/runtime/src/test/resources/application.properties +++ b/extensions/resteasy-classic/rest-client-config/runtime/src/test/resources/application.properties @@ -54,3 +54,12 @@ mp.key/mp-rest/proxyAddress=mp.key mp.key/mp-rest/queryParamStyle=COMMA_SEPARATED MPConfigKeyRestClient/mp-rest/uri=http://localhost:8082 + +quarkus.rest-client.one.uri=http://localhost:8081 +quarkus.rest-client.two.uri=http://localhost:8082 + +quarkus.rest-client."io.quarkus.restclient.config.key.SharedOneConfigKeyRestClient".uri=http://localhost:8081 +quarkus.rest-client."io.quarkus.restclient.config.key.SharedTwoConfigKeyRestClient".uri=http://localhost:8082 +io.quarkus.restclient.config.key.SharedThreeConfigKeyRestClient/mp-rest/uri=http://localhost:8083 +quarkus.rest-client.shared.headers.two=one +quarkus.rest-client.shared.headers.one=two diff --git a/extensions/resteasy-classic/resteasy-common/spi/src/main/java/io/quarkus/resteasy/common/spi/EndpointValidationPredicatesBuildItem.java b/extensions/resteasy-classic/resteasy-common/spi/src/main/java/io/quarkus/resteasy/common/spi/EndpointValidationPredicatesBuildItem.java new file mode 100644 index 0000000000000..2249040e483f4 --- /dev/null +++ b/extensions/resteasy-classic/resteasy-common/spi/src/main/java/io/quarkus/resteasy/common/spi/EndpointValidationPredicatesBuildItem.java @@ -0,0 +1,33 @@ +package io.quarkus.resteasy.common.spi; + +import java.util.function.Predicate; + +import org.jboss.jandex.ClassInfo; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * A build item that provides a {@link Predicate} to detect and validate classes defining REST endpoints. + *

+ * This can include resources in RESTEasy or controllers in the Spring ecosystem. + * It acts as a Service Provider Interface (SPI) to allow customization of the validation logic for endpoint detection, + * enabling integration with various frameworks or specific application needs. + *

+ * + *

+ * The {@link Predicate} evaluates {@link ClassInfo} instances to determine whether a class defines a REST endpoint + * according to the provided logic. + *

+ */ +public final class EndpointValidationPredicatesBuildItem extends MultiBuildItem { + + private final Predicate predicate; + + public EndpointValidationPredicatesBuildItem(Predicate predicate) { + this.predicate = predicate; + } + + public Predicate getPredicate() { + return predicate; + } +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AbstractCustomExceptionMapperTest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AbstractCustomExceptionMapperTest.java new file mode 100644 index 0000000000000..55ef288f5c5da --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AbstractCustomExceptionMapperTest.java @@ -0,0 +1,164 @@ +package io.quarkus.resteasy.test.security; + +import java.util.Map; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +import org.hamcrest.Matchers; +import org.jboss.resteasy.spi.UnhandledException; +import org.junit.jupiter.api.Test; + +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.SecurityIdentityAugmentor; +import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; +import io.restassured.RestAssured; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +/** + * Tests internal server errors and other custom exceptions raised during + * proactive authentication can be handled by the exception mappers. + * For lazy authentication, it is important that these exceptions raised during authentication + * required by HTTP permissions are also propagated. + */ +public abstract class AbstractCustomExceptionMapperTest { + + @Test + public void testNoExceptions() { + RestAssured.given() + .auth().preemptive().basic("gaston", "gaston-password") + .get("/hello") + .then() + .statusCode(200) + .body(Matchers.is("Hello Gaston")); + RestAssured.given() + .get("/hello") + .then() + .statusCode(401); + } + + @Test + public void testUnhandledRuntimeException() { + // UnhandledRuntimeException has no exception mapper therefore RESTEasy would wrap it in UnhandledException + // if we started RESTEasy even though there is no matching exception mapper + RestAssured.given() + .auth().preemptive().basic("gaston", "gaston-password") + .header("fail-unhandled", "true") + .get("/hello") + .then() + .statusCode(500) + .body(Matchers.not(Matchers.is(UnhandledException.class.getName()))) + .body(Matchers.containsString(UnhandledRuntimeException.class.getName())) + .body(Matchers.containsString("Expected unhandled failure")); + } + + @Test + public void testCustomExceptionInIdentityProvider() { + RestAssured.given() + .auth().preemptive().basic("gaston", "gaston-password") + .header("fail-authentication", "true") + .get("/hello") + .then() + .statusCode(500) + .body(Matchers.is("Expected authentication failure")); + } + + @Test + public void testCustomExceptionInIdentityAugmentor() { + RestAssured.given() + .auth().preemptive().basic("gaston", "gaston-password") + .header("fail-augmentation", "true") + .get("/hello") + .then() + .statusCode(500) + .body(Matchers.is("Expected identity augmentation failure")); + } + + @Path("/hello") + public static class HelloResource { + @GET + public String hello(@Context SecurityContext context) { + var principalName = context.getUserPrincipal() == null ? "" : " " + context.getUserPrincipal().getName(); + return "Hello" + principalName; + } + + } + + @Provider + public static class CustomRuntimeExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(CustomRuntimeException exception) { + return Response.serverError().entity(exception.getMessage()).build(); + } + } + + @ApplicationScoped + public static class CustomIdentityAugmentor implements SecurityIdentityAugmentor { + @Override + public Uni augment(SecurityIdentity securityIdentity, + AuthenticationRequestContext authenticationRequestContext) { + return augment(securityIdentity, authenticationRequestContext, Map.of()); + } + + @Override + public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context, + Map attributes) { + final RoutingContext routingContext = HttpSecurityUtils.getRoutingContextAttribute(attributes); + if (routingContext.request().headers().contains("fail-augmentation")) { + return Uni.createFrom().failure(new CustomRuntimeException("Expected identity augmentation failure")); + } + return Uni.createFrom().item(identity); + } + } + + public static class CustomRuntimeException extends RuntimeException { + public CustomRuntimeException(String message) { + super(message); + } + } + + public static class UnhandledRuntimeException extends RuntimeException { + public UnhandledRuntimeException(String message) { + super(message); + } + } + + @ApplicationScoped + public static class BasicIdentityProvider implements IdentityProvider { + + @Override + public Class getRequestType() { + return UsernamePasswordAuthenticationRequest.class; + } + + @Override + public Uni authenticate(UsernamePasswordAuthenticationRequest authRequest, + AuthenticationRequestContext authRequestCtx) { + if (!"gaston".equals(authRequest.getUsername())) { + return Uni.createFrom().nullItem(); + } + + final RoutingContext routingContext = HttpSecurityUtils.getRoutingContextAttribute(authRequest); + if (routingContext.request().headers().contains("fail-authentication")) { + return Uni.createFrom().failure(new CustomRuntimeException("Expected authentication failure")); + } + if (routingContext.request().headers().contains("fail-unhandled")) { + return Uni.createFrom().failure(new UnhandledRuntimeException("Expected unhandled failure")); + } + return Uni.createFrom() + .item(QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal("Gaston")).build()); + } + } +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/LazyAuthCustomExceptionMapperTest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/LazyAuthCustomExceptionMapperTest.java new file mode 100644 index 0000000000000..5085343e29649 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/LazyAuthCustomExceptionMapperTest.java @@ -0,0 +1,18 @@ +package io.quarkus.resteasy.test.security; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class LazyAuthCustomExceptionMapperTest extends AbstractCustomExceptionMapperTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest().withApplicationRoot(jar -> jar + .addAsResource(new StringAsset(""" + quarkus.http.auth.permission.authentication.paths=* + quarkus.http.auth.permission.authentication.policy=authenticated + quarkus.http.auth.proactive=false + """), "application.properties")); + +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/ProactiveAuthCustomExceptionMapperTest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/ProactiveAuthCustomExceptionMapperTest.java new file mode 100644 index 0000000000000..c4bd59e5e8a31 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/ProactiveAuthCustomExceptionMapperTest.java @@ -0,0 +1,17 @@ +package io.quarkus.resteasy.test.security; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class ProactiveAuthCustomExceptionMapperTest extends AbstractCustomExceptionMapperTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest().withApplicationRoot(jar -> jar + .addAsResource(new StringAsset(""" + quarkus.http.auth.permission.authentication.paths=* + quarkus.http.auth.permission.authentication.policy=authenticated + """), "application.properties")); + +} diff --git a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java index 9de7cb651a3b1..8511466a0500c 100644 --- a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java +++ b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java @@ -1,6 +1,9 @@ package io.quarkus.resteasy.runtime.standalone; import static io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.DefaultAuthFailureHandler.extractRootCause; +import static io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.DefaultAuthFailureHandler.isOtherAuthenticationFailure; +import static io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.DefaultAuthFailureHandler.markIfOtherAuthenticationFailure; +import static io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.DefaultAuthFailureHandler.removeMarkAsOtherAuthenticationFailure; import java.lang.annotation.Annotation; import java.lang.reflect.Proxy; @@ -37,6 +40,7 @@ import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.AuthenticationRedirectException; import io.quarkus.security.ForbiddenException; +import io.quarkus.security.UnauthorizedException; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; import io.quarkus.vertx.http.runtime.HttpCompressionHandler; import io.quarkus.vertx.http.runtime.HttpConfiguration; @@ -163,8 +167,16 @@ public void handle(RoutingContext request) { } } - if (request.failure() instanceof AuthenticationException - || request.failure() instanceof ForbiddenException) { + final Throwable failure = request.failure(); + final boolean isOtherAuthFailure = isOtherAuthenticationFailure(request) + && isFailureHandledByExceptionMappers(failure); + if (isOtherAuthFailure) { + // prevent circular reference for unhandled exceptions + // (which is unnecessary if everything here is done right) + removeMarkAsOtherAuthenticationFailure(request); + super.handle(request); + } else if (failure instanceof AuthenticationException || failure instanceof UnauthorizedException + || failure instanceof ForbiddenException) { super.handle(request); } else { request.next(); @@ -179,6 +191,11 @@ protected void setCurrentIdentityAssociation(RoutingContext routingContext) { } } + private boolean isFailureHandledByExceptionMappers(Throwable failure) { + return failure != null && deployment != null + && deployment.getProviderFactory().getExceptionMapper(failure.getClass()) != null; + } + public Handler defaultAuthFailureHandler() { return new Handler() { @Override @@ -204,6 +221,7 @@ public void handle(RoutingContext event) { @Override public void accept(RoutingContext event, Throwable throwable) { + markIfOtherAuthenticationFailure(event, throwable); if (!event.failed()) { event.fail(extractRootCause(throwable)); } diff --git a/extensions/resteasy-reactive/rest-client-jaxrs/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java b/extensions/resteasy-reactive/rest-client-jaxrs/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java index 2f4ac2e80302f..ba00737128b1c 100644 --- a/extensions/resteasy-reactive/rest-client-jaxrs/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest-client-jaxrs/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java @@ -50,6 +50,7 @@ import java.util.function.Predicate; import java.util.function.Supplier; import java.util.regex.Pattern; +import java.util.stream.Collectors; import jakarta.ws.rs.ProcessingException; import jakarta.ws.rs.RuntimeType; @@ -177,6 +178,7 @@ import io.quarkus.resteasy.reactive.common.deployment.ResourceScanningResultBuildItem; import io.quarkus.resteasy.reactive.common.deployment.SerializersUtil; import io.quarkus.resteasy.reactive.common.runtime.ResteasyReactiveConfig; +import io.quarkus.resteasy.reactive.spi.EndpointValidationPredicatesBuildItem; import io.quarkus.resteasy.reactive.spi.MessageBodyReaderBuildItem; import io.quarkus.resteasy.reactive.spi.MessageBodyReaderOverrideBuildItem; import io.quarkus.resteasy.reactive.spi.MessageBodyWriterBuildItem; @@ -289,7 +291,8 @@ void setupClientProxies(JaxrsClientReactiveRecorder recorder, List defaultProduces, List disableSmartDefaultProduces, List disableRemovalTrailingSlashProduces, - List parameterContainersBuildItems) { + List parameterContainersBuildItems, + List validationPredicatesBuildItems) { String defaultConsumesType = defaultMediaType(defaultConsumes, MediaType.APPLICATION_OCTET_STREAM); String defaultProducesType = defaultMediaType(defaultProduces, MediaType.TEXT_PLAIN); @@ -343,6 +346,8 @@ public boolean test(Map anns) { return anns.containsKey(NOT_BODY) || anns.containsKey(URL); } }) + .setValidateEndpoint(validationPredicatesBuildItems.stream().map(item -> item.getPredicate()) + .collect(Collectors.toUnmodifiableList())) .setResourceMethodCallback(new Consumer<>() { @Override public void accept(EndpointIndexer.ResourceMethodCallbackEntry entry) { @@ -955,8 +960,7 @@ A more full example of generated client (with sub-resource) can is at the bottom classContext.constructor.getThis(), baseTarget)); if (observabilityIntegrationNeeded) { - String templatePath = MULTIPLE_SLASH_PATTERN.matcher(restClientInterface.getPath() + method.getPath()) - .replaceAll("/"); + String templatePath = templatePath(restClientInterface, method); classContext.constructor.invokeVirtualMethod( MethodDescriptor.ofMethod(WebTargetImpl.class, "setPreClientSendHandler", void.class, ClientRestHandler.class), @@ -1012,11 +1016,25 @@ A more full example of generated client (with sub-resource) can is at the bottom + jandexMethod.name()); } - ResultHandle newInputTarget = methodParamNotNull.invokeVirtualMethod( - MethodDescriptor.ofMethod(WebTargetImpl.class, "withNewUri", WebTargetImpl.class, - java.net.URI.class), - methodParamNotNull.readInstanceField(inputTargetField, methodParamNotNull.getThis()), - newUri); + ResultHandle newInputTarget; + if (observabilityIntegrationNeeded) { + // we need to apply the ClientObservabilityHandler to the inputTarget field without altering it + newInputTarget = methodParamNotNull.invokeVirtualMethod( + MethodDescriptor.ofMethod(WebTargetImpl.class, "withNewUri", WebTargetImpl.class, + java.net.URI.class, ClientRestHandler.class), + methodParamNotNull.readInstanceField(inputTargetField, methodParamNotNull.getThis()), + newUri, + methodParamNotNull.newInstance( + MethodDescriptor.ofConstructor(ClientObservabilityHandler.class, String.class), + methodParamNotNull.load(templatePath(restClientInterface, method)))); + } else { + // just read the inputTarget field and call withNewUri on it + newInputTarget = methodParamNotNull.invokeVirtualMethod( + MethodDescriptor.ofMethod(WebTargetImpl.class, "withNewUri", WebTargetImpl.class, + java.net.URI.class), + methodParamNotNull.readInstanceField(inputTargetField, methodParamNotNull.getThis()), + newUri); + } ResultHandle newBaseTarget = methodParamNotNull.invokeVirtualMethod( baseTargetProducer.getMethodDescriptor(), methodParamNotNull.getThis(), newInputTarget); @@ -1247,6 +1265,11 @@ A more full example of generated client (with sub-resource) can is at the bottom } + private String templatePath(RestClientInterface restClientInterface, ResourceMethod method) { + return MULTIPLE_SLASH_PATTERN.matcher(restClientInterface.getPath() + method.getPath()) + .replaceAll("/"); + } + /** * The @Encoded annotation is only supported in path/query/matrix/form params. */ diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/DevServicesRestClientHttpProxyProcessor.java b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/DevServicesRestClientHttpProxyProcessor.java index 6e2eb61e3f12c..8672ee205a985 100644 --- a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/DevServicesRestClientHttpProxyProcessor.java +++ b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/DevServicesRestClientHttpProxyProcessor.java @@ -211,10 +211,12 @@ public void start(List restClientHttpProxyBuildIte var urlKeyName = String.format("quarkus.rest-client.\"%s\".override-uri", bi.getClassName()); var urlKeyValue = String.format("http://%s:%d", createResult.host(), createResult.port()); - if (baseUri.getPath() != null) { - if (!"/".equals(baseUri.getPath()) && !baseUri.getPath().isEmpty()) { - urlKeyValue = urlKeyValue + "/" + baseUri.getPath(); + String basePath = baseUri.getPath(); + if ((basePath != null) && !basePath.isEmpty()) { + if (basePath.startsWith("/")) { + basePath = basePath.substring(1); } + urlKeyValue = urlKeyValue + "/" + basePath; } devServicePropertiesProducer.produce( diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/VertxHttpProxyDevServicesRestClientProxyProvider.java b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/VertxHttpProxyDevServicesRestClientProxyProvider.java index 4965a4d35c457..73d457ce4a3ca 100644 --- a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/VertxHttpProxyDevServicesRestClientProxyProvider.java +++ b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/VertxHttpProxyDevServicesRestClientProxyProvider.java @@ -15,7 +15,6 @@ import io.quarkus.rest.client.reactive.spi.RestClientHttpProxyBuildItem; import io.quarkus.runtime.ResettableSystemProperties; import io.vertx.core.Future; -import io.vertx.core.MultiMap; import io.vertx.core.Vertx; import io.vertx.core.VertxOptions; import io.vertx.core.file.FileSystemOptions; @@ -23,6 +22,7 @@ import io.vertx.core.http.HttpClientOptions; import io.vertx.core.http.HttpServer; import io.vertx.core.metrics.MetricsOptions; +import io.vertx.core.net.HostAndPort; import io.vertx.httpproxy.HttpProxy; import io.vertx.httpproxy.ProxyContext; import io.vertx.httpproxy.ProxyInterceptor; @@ -71,16 +71,18 @@ public CreateResult create(RestClientHttpProxyBuildItem buildItem) { } HttpClient proxyClient = vertx.get().createHttpClient(clientOptions); HttpProxy proxy = HttpProxy.reverseProxy(proxyClient); - proxy.origin(determineOriginPort(baseUri), baseUri.getHost()) - .addInterceptor(new HostSettingInterceptor(baseUri.getHost())); + int targetPort = determineOriginPort(baseUri); + String targetHost = baseUri.getHost(); + proxy.origin(targetPort, targetHost) + .addInterceptor(new AuthoritySettingInterceptor(targetPort, targetHost)); HttpServer proxyServer = vertx.get().createHttpServer(); - Integer port = findRandomPort(); - proxyServer.requestHandler(proxy).listen(port); + Integer proxyPort = findRandomPort(); + proxyServer.requestHandler(proxy).listen(proxyPort); - logStartup(buildItem.getClassName(), port); + logStartup(buildItem.getClassName(), proxyPort); - return new CreateResult("localhost", port, new HttpServerClosable(proxyServer)); + return new CreateResult("localhost", proxyPort, new HttpServerClosable(proxyServer)); } protected void logStartup(String className, Integer port) { @@ -124,19 +126,18 @@ private Integer findRandomPort() { * This class sets the Host HTTP Header in order to avoid having services being blocked * for presenting a wrong value */ - private static class HostSettingInterceptor implements ProxyInterceptor { + private static class AuthoritySettingInterceptor implements ProxyInterceptor { - private final String host; + private final HostAndPort authority; - private HostSettingInterceptor(String host) { - this.host = host; + private AuthoritySettingInterceptor(int targetPort, String host) { + this.authority = HostAndPort.authority(host, targetPort); } @Override public Future handleProxyRequest(ProxyContext context) { ProxyRequest request = context.request(); - MultiMap headers = request.headers(); - headers.set("Host", host); + request.setAuthority(authority); return context.sendRequest(); } diff --git a/extensions/resteasy-reactive/rest-common/spi-deployment/src/main/java/io/quarkus/resteasy/reactive/spi/EndpointValidationPredicatesBuildItem.java b/extensions/resteasy-reactive/rest-common/spi-deployment/src/main/java/io/quarkus/resteasy/reactive/spi/EndpointValidationPredicatesBuildItem.java new file mode 100644 index 0000000000000..d50365bfb0dfb --- /dev/null +++ b/extensions/resteasy-reactive/rest-common/spi-deployment/src/main/java/io/quarkus/resteasy/reactive/spi/EndpointValidationPredicatesBuildItem.java @@ -0,0 +1,33 @@ +package io.quarkus.resteasy.reactive.spi; + +import java.util.function.Predicate; + +import org.jboss.jandex.ClassInfo; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * A build item that provides a {@link Predicate} to detect and validate classes defining REST endpoints. + *

+ * This can include resources in RESTEasy or controllers in the Spring ecosystem. + * It acts as a Service Provider Interface (SPI) to allow customization of the validation logic for endpoint detection, + * enabling integration with various frameworks or specific application needs. + *

+ * + *

+ * The {@link Predicate} evaluates {@link ClassInfo} instances to determine whether a class defines a REST endpoint + * according to the provided logic. + *

+ */ +public final class EndpointValidationPredicatesBuildItem extends MultiBuildItem { + + private final Predicate predicate; + + public EndpointValidationPredicatesBuildItem(Predicate predicate) { + this.predicate = predicate; + } + + public Predicate getPredicate() { + return predicate; + } +} diff --git a/extensions/resteasy-reactive/rest-jackson/deployment/src/main/java/io/quarkus/resteasy/reactive/jackson/deployment/processor/JacksonCodeGenerator.java b/extensions/resteasy-reactive/rest-jackson/deployment/src/main/java/io/quarkus/resteasy/reactive/jackson/deployment/processor/JacksonCodeGenerator.java index b06abe3deaf15..7380036811cd9 100644 --- a/extensions/resteasy-reactive/rest-jackson/deployment/src/main/java/io/quarkus/resteasy/reactive/jackson/deployment/processor/JacksonCodeGenerator.java +++ b/extensions/resteasy-reactive/rest-jackson/deployment/src/main/java/io/quarkus/resteasy/reactive/jackson/deployment/processor/JacksonCodeGenerator.java @@ -13,12 +13,14 @@ import java.util.function.Function; import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; import org.jboss.jandex.AnnotationValue; import org.jboss.jandex.ArrayType; import org.jboss.jandex.ClassInfo; import org.jboss.jandex.FieldInfo; import org.jboss.jandex.IndexView; import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.MethodParameterInfo; import org.jboss.jandex.ParameterizedType; import org.jboss.jandex.Type; import org.jboss.jandex.TypeVariable; @@ -240,6 +242,10 @@ protected FieldSpecs fieldSpecsFromField(ClassInfo classInfo, FieldInfo fieldInf return null; } + protected FieldSpecs fieldSpecsFromFieldParam(MethodParameterInfo paramInfo) { + return new FieldSpecs(paramInfo); + } + protected static class FieldSpecs { final String fieldName; @@ -262,17 +268,28 @@ protected static class FieldSpecs { FieldSpecs(FieldInfo fieldInfo, MethodInfo methodInfo) { if (fieldInfo != null) { this.fieldInfo = fieldInfo; - fieldInfo.annotations().forEach(a -> annotations.put(a.name().toString(), a)); + readAnnotations(fieldInfo); } if (methodInfo != null) { this.methodInfo = methodInfo; - methodInfo.annotations().forEach(a -> annotations.put(a.name().toString(), a)); + readAnnotations(methodInfo); } this.fieldType = fieldType(); this.fieldName = fieldName(); this.jsonName = jsonName(); } + FieldSpecs(MethodParameterInfo paramInfo) { + readAnnotations(paramInfo); + this.fieldType = paramInfo.type(); + this.fieldName = paramInfo.name(); + this.jsonName = jsonName(); + } + + private void readAnnotations(AnnotationTarget target) { + target.annotations().forEach(a -> annotations.put(a.name().toString(), a)); + } + public boolean isPublicField() { return fieldInfo != null && Modifier.isPublic(fieldInfo.flags()); } @@ -295,7 +312,7 @@ private String jsonName() { return value.asString(); } } - return fieldName(); + return fieldName; } private String fieldName() { diff --git a/extensions/resteasy-reactive/rest-jackson/deployment/src/main/java/io/quarkus/resteasy/reactive/jackson/deployment/processor/JacksonDeserializerFactory.java b/extensions/resteasy-reactive/rest-jackson/deployment/src/main/java/io/quarkus/resteasy/reactive/jackson/deployment/processor/JacksonDeserializerFactory.java index ebc47ab056de6..3723d52927ddf 100644 --- a/extensions/resteasy-reactive/rest-jackson/deployment/src/main/java/io/quarkus/resteasy/reactive/jackson/deployment/processor/JacksonDeserializerFactory.java +++ b/extensions/resteasy-reactive/rest-jackson/deployment/src/main/java/io/quarkus/resteasy/reactive/jackson/deployment/processor/JacksonDeserializerFactory.java @@ -201,20 +201,20 @@ protected String[] getInterfacesNames(ClassInfo classInfo) { @Override protected boolean createSerializationMethod(ClassInfo classInfo, ClassCreator classCreator, String beanClassName) { - if (!classInfo.hasNoArgsConstructor()) { - return false; - } - MethodCreator deserialize = classCreator .getMethodCreator("deserialize", Object.class, JsonParser.class, DeserializationContext.class) .setModifiers(ACC_PUBLIC) .addException(IOException.class) .addException(JacksonException.class); - ResultHandle deserializedHandle = deserialize - .newInstance(MethodDescriptor.ofConstructor(classInfo.name().toString())); + DeserializationData deserData = new DeserializationData(classInfo, classCreator, deserialize, + getJsonNode(deserialize), parseTypeParameters(classInfo, classCreator), new HashSet<>()); + ResultHandle deserializedHandle = createDeserializedObject(deserData); + if (deserializedHandle == null) { + return false; + } - boolean valid = deserializeObject(classInfo, deserializedHandle, classCreator, deserialize); + boolean valid = deserializeObjectFields(deserData, deserializedHandle); deserialize.returnValue(deserializedHandle); return valid; } @@ -229,13 +229,35 @@ private static ResultHandle getJsonNode(MethodCreator deserialize) { return deserialize.checkCast(treeNode, JsonNode.class); } - private boolean deserializeObject(ClassInfo classInfo, ResultHandle objHandle, ClassCreator classCreator, - MethodCreator deserialize) { - ResultHandle jsonNode = getJsonNode(deserialize); + private ResultHandle createDeserializedObject(DeserializationData deserData) { + if (deserData.classInfo.hasNoArgsConstructor()) { + return deserData.methodCreator.newInstance(MethodDescriptor.ofConstructor(deserData.classInfo.name().toString())); + } - ResultHandle fieldsIterator = deserialize - .invokeVirtualMethod(ofMethod(JsonNode.class, "fields", Iterator.class), jsonNode); - BytecodeCreator loopCreator = deserialize.whileLoop(c -> iteratorHasNext(c, fieldsIterator)).block(); + var ctorOpt = deserData.classInfo.constructors().stream().filter(ctor -> Modifier.isPublic(ctor.flags())).findFirst(); + if (!ctorOpt.isPresent()) { + return null; + } + MethodInfo ctor = ctorOpt.get(); + ResultHandle[] params = new ResultHandle[ctor.parameters().size()]; + int i = 0; + for (MethodParameterInfo paramInfo : ctor.parameters()) { + FieldSpecs fieldSpecs = fieldSpecsFromFieldParam(paramInfo); + deserData.constructorFields.add(fieldSpecs.jsonName); + ResultHandle fieldValue = deserData.methodCreator.invokeVirtualMethod( + ofMethod(JsonNode.class, "get", JsonNode.class, String.class), deserData.jsonNode, + deserData.methodCreator.load(fieldSpecs.jsonName)); + params[i++] = readValueFromJson(deserData.classCreator, deserData.methodCreator, + deserData.methodCreator.getMethodParam(1), fieldSpecs, deserData.typeParametersIndex, fieldValue); + } + return deserData.methodCreator.newInstance(ctor, params); + } + + private boolean deserializeObjectFields(DeserializationData deserData, ResultHandle objHandle) { + + ResultHandle fieldsIterator = deserData.methodCreator + .invokeVirtualMethod(ofMethod(JsonNode.class, "fields", Iterator.class), deserData.jsonNode); + BytecodeCreator loopCreator = deserData.methodCreator.whileLoop(c -> iteratorHasNext(c, fieldsIterator)).block(); ResultHandle nextField = loopCreator .invokeInterfaceMethod(ofMethod(Iterator.class, "next", Object.class), fieldsIterator); ResultHandle mapEntry = loopCreator.checkCast(nextField, Map.Entry.class); @@ -250,8 +272,8 @@ private boolean deserializeObject(ClassInfo classInfo, ResultHandle objHandle, C .invokeInterfaceMethod(ofMethod(Map.Entry.class, "getKey", Object.class), mapEntry); Switch.StringSwitch strSwitch = fieldReader.stringSwitch(fieldName); - return deserializeFields(classCreator, classInfo, deserialize.getMethodParam(1), objHandle, fieldValue, new HashSet<>(), - strSwitch, parseTypeParameters(classInfo, classCreator)); + return deserializeFields(deserData, deserData.methodCreator.getMethodParam(1), objHandle, fieldValue, + deserData.constructorFields, strSwitch); } private BranchResult iteratorHasNext(BytecodeCreator creator, ResultHandle iterator) { @@ -294,50 +316,50 @@ private static void createContextualMethod(ClassCreator classCreator) { createContextual.returnValue(deserializer); } - private boolean deserializeFields(ClassCreator classCreator, ClassInfo classInfo, ResultHandle deserializationContext, - ResultHandle objHandle, ResultHandle fieldValue, Set deserializedFields, Switch.StringSwitch strSwitch, - Map typeParametersIndex) { + private boolean deserializeFields(DeserializationData deserData, ResultHandle deserializationContext, + ResultHandle objHandle, ResultHandle fieldValue, Set deserializedFields, Switch.StringSwitch strSwitch) { AtomicBoolean valid = new AtomicBoolean(true); - for (FieldInfo fieldInfo : classFields(classInfo)) { - if (!deserializeFieldSpecs(classCreator, classInfo, deserializationContext, objHandle, fieldValue, - deserializedFields, strSwitch, typeParametersIndex, fieldSpecsFromField(classInfo, fieldInfo), valid)) + for (FieldInfo fieldInfo : classFields(deserData.classInfo)) { + if (!deserializeFieldSpecs(deserData, deserializationContext, objHandle, fieldValue, + deserializedFields, strSwitch, fieldSpecsFromField(deserData.classInfo, fieldInfo), valid)) return false; } - for (MethodInfo methodInfo : classMethods(classInfo)) { - if (!deserializeFieldSpecs(classCreator, classInfo, deserializationContext, objHandle, fieldValue, - deserializedFields, strSwitch, typeParametersIndex, fieldSpecsFromMethod(methodInfo), valid)) + for (MethodInfo methodInfo : classMethods(deserData.classInfo)) { + if (!deserializeFieldSpecs(deserData, deserializationContext, objHandle, fieldValue, + deserializedFields, strSwitch, fieldSpecsFromMethod(methodInfo), valid)) return false; } return valid.get(); } - private boolean deserializeFieldSpecs(ClassCreator classCreator, ClassInfo classInfo, ResultHandle deserializationContext, + private boolean deserializeFieldSpecs(DeserializationData deserData, ResultHandle deserializationContext, ResultHandle objHandle, ResultHandle fieldValue, Set deserializedFields, Switch.StringSwitch strSwitch, - Map typeParametersIndex, FieldSpecs fieldSpecs, AtomicBoolean valid) { - if (fieldSpecs != null && deserializedFields.add(fieldSpecs.fieldName)) { + FieldSpecs fieldSpecs, AtomicBoolean valid) { + if (fieldSpecs != null && deserializedFields.add(fieldSpecs.jsonName)) { if (fieldSpecs.hasUnknownAnnotation()) { return false; } strSwitch.caseOf(fieldSpecs.jsonName, - bytecode -> valid.compareAndSet(true, deserializeField(classCreator, classInfo, bytecode, objHandle, - fieldValue, typeParametersIndex, fieldSpecs, deserializationContext))); + bytecode -> valid.compareAndSet(true, deserializeField(deserData, bytecode, objHandle, + fieldValue, fieldSpecs, deserializationContext))); } return true; } - private boolean deserializeField(ClassCreator classCreator, ClassInfo classInfo, BytecodeCreator bytecode, - ResultHandle objHandle, ResultHandle fieldValue, Map typeParametersIndex, FieldSpecs fieldSpecs, + private boolean deserializeField(DeserializationData deserData, BytecodeCreator bytecode, + ResultHandle objHandle, ResultHandle fieldValue, FieldSpecs fieldSpecs, ResultHandle deserializationContext) { - ResultHandle valueHandle = readValueFromJson(classCreator, bytecode, deserializationContext, fieldSpecs, - typeParametersIndex, fieldValue); + ResultHandle valueHandle = readValueFromJson(deserData.classCreator, bytecode, deserializationContext, fieldSpecs, + deserData.typeParametersIndex, fieldValue); if (valueHandle == null) { return false; } - writeValueToObject(classInfo, objHandle, fieldSpecs, bytecode, fieldSpecs.toValueWriterHandle(bytecode, valueHandle)); + writeValueToObject(deserData.classInfo, objHandle, fieldSpecs, bytecode, + fieldSpecs.toValueWriterHandle(bytecode, valueHandle)); return true; } @@ -445,4 +467,8 @@ private MethodDescriptor readMethodForPrimitiveFields(String typeName) { protected boolean shouldGenerateCodeFor(ClassInfo classInfo) { return super.shouldGenerateCodeFor(classInfo) && classInfo.hasNoArgsConstructor(); } + + private record DeserializationData(ClassInfo classInfo, ClassCreator classCreator, MethodCreator methodCreator, + ResultHandle jsonNode, Map typeParametersIndex, Set constructorFields) { + } } diff --git a/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/SimpleJsonResource.java b/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/SimpleJsonResource.java index 2b79bbadada79..950521f563e59 100644 --- a/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/SimpleJsonResource.java +++ b/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/SimpleJsonResource.java @@ -112,7 +112,7 @@ public Dog echoDog(Dog dog) { @POST @Path("/record-echo") @Consumes(MediaType.APPLICATION_JSON) - public StateRecord echoDog(StateRecord stateRecord) { + public StateRecord echoRecord(StateRecord stateRecord) { return stateRecord; } diff --git a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java index 35ce4ab24bdde..413492800c160 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java @@ -191,6 +191,7 @@ import io.quarkus.resteasy.reactive.server.spi.AllowNotRestParametersBuildItem; import io.quarkus.resteasy.reactive.server.spi.AnnotationsTransformerBuildItem; import io.quarkus.resteasy.reactive.server.spi.ContextTypeBuildItem; +import io.quarkus.resteasy.reactive.server.spi.GlobalHandlerCustomizerBuildItem; import io.quarkus.resteasy.reactive.server.spi.HandlerConfigurationProviderBuildItem; import io.quarkus.resteasy.reactive.server.spi.MethodScannerBuildItem; import io.quarkus.resteasy.reactive.server.spi.NonBlockingReturnTypeBuildItem; @@ -198,6 +199,7 @@ import io.quarkus.resteasy.reactive.server.spi.ResumeOn404BuildItem; import io.quarkus.resteasy.reactive.spi.CustomExceptionMapperBuildItem; import io.quarkus.resteasy.reactive.spi.DynamicFeatureBuildItem; +import io.quarkus.resteasy.reactive.spi.EndpointValidationPredicatesBuildItem; import io.quarkus.resteasy.reactive.spi.ExceptionMapperBuildItem; import io.quarkus.resteasy.reactive.spi.JaxrsFeatureBuildItem; import io.quarkus.resteasy.reactive.spi.MessageBodyReaderBuildItem; @@ -468,7 +470,8 @@ public void setupEndpoints(ApplicationIndexBuildItem applicationIndexBuildItem, CompiledJavaVersionBuildItem compiledJavaVersionBuildItem, ResourceInterceptorsBuildItem resourceInterceptorsBuildItem, Capabilities capabilities, - Optional allowNotRestParametersBuildItem) { + Optional allowNotRestParametersBuildItem, + List validationPredicatesBuildItems) { if (!resourceScanningResultBuildItem.isPresent()) { // no detected @Path, bail out @@ -640,6 +643,8 @@ private boolean hasAnnotation(MethodInfo method, short paramPosition, DotName an }) .setResteasyReactiveRecorder(recorder) .setApplicationClassPredicate(applicationClassPredicate) + .setValidateEndpoint(validationPredicatesBuildItems.stream().map(item -> item.getPredicate()) + .collect(Collectors.toUnmodifiableList())) .setTargetJavaVersion(new TargetJavaVersion() { private final Status result; @@ -1232,6 +1237,11 @@ public void additionalReflection(BeanArchiveIndexBuildItem beanArchiveIndexBuild } } + @BuildStep + public GlobalHandlerCustomizerBuildItem securityContextOverrideHandler() { + return new GlobalHandlerCustomizerBuildItem(new SecurityContextOverrideHandler.Customizer()); + } + @BuildStep @Record(value = ExecutionTime.STATIC_INIT, useIdentityComparisonForParameters = false) public void setupDeployment(BeanContainerBuildItem beanContainerBuildItem, @@ -1260,7 +1270,8 @@ public void setupDeployment(BeanContainerBuildItem beanContainerBuildItem, ContextResolversBuildItem contextResolversBuildItem, ResteasyReactiveServerConfig serverConfig, LaunchModeBuildItem launchModeBuildItem, - List resumeOn404Items) + List resumeOn404Items, + List globalHandlerCustomizers) throws NoSuchMethodException { if (!resourceScanningResultBuildItem.isPresent()) { @@ -1361,7 +1372,8 @@ public void setupDeployment(BeanContainerBuildItem beanContainerBuildItem, .setSerialisers(serialisers) .setPreExceptionMapperHandler(determinePreExceptionMapperHandler(preExceptionMapperHandlerBuildItems)) .setApplicationPath(applicationPath) - .setGlobalHandlerCustomizers(Collections.singletonList(new SecurityContextOverrideHandler.Customizer())) //TODO: should be pluggable + .setGlobalHandlerCustomizers(globalHandlerCustomizers.stream().map( + GlobalHandlerCustomizerBuildItem::getCustomizer).toList()) .setResourceClasses(resourceClasses) .setDevelopmentMode(launchModeBuildItem.getLaunchMode() == LaunchMode.DEVELOPMENT) .setLocatableResourceClasses(subResourceClasses) diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/CancelableUniTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/CancelableUniTest.java index 3b1f003566ff7..ca673fba96569 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/CancelableUniTest.java +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/CancelableUniTest.java @@ -15,6 +15,7 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.server.Cancellable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -44,17 +45,35 @@ void setUp() { @Test public void testNormal() { - when().get("test") + doTestNormal("1"); + } + + @Test + public void testDefaultCancellable() { + doTestCancel("1", Resource.COUNT, 1); + } + + @Test + public void testUnCancellable() { + doTestCancel("2", Resource.COUNT, 2); + } + + @Test + public void testCancellable() { + doTestCancel("3", Resource.COUNT, 1); + } + + private void doTestNormal(String path) { + when().get("test/" + path) .then() .statusCode(200) .body(equalTo("Hello, world")); } - @Test - public void testCancel() { + private void doTestCancel(String path, AtomicInteger count, int expected) { WebClient client = WebClient.create(vertx); - client.get(url.getPort(), url.getHost(), "/test").send(); + client.get(url.getPort(), url.getHost(), "/test/" + path).send(); try { // make sure we did make the proper request @@ -67,7 +86,7 @@ public void testCancel() { Thread.sleep(7_000); // if the count did not increase, it means that Uni was cancelled - assertEquals(1, Resource.COUNT.get()); + assertEquals(expected, count.get()); } catch (InterruptedException ignored) { } finally { @@ -77,7 +96,6 @@ public void testCancel() { } } - } @Path("test") @@ -87,7 +105,28 @@ public static class Resource { @GET @Produces(MediaType.TEXT_PLAIN) - public Uni hello() { + @Path("1") + public Uni defaultCancelableHello() { + COUNT.incrementAndGet(); + return Uni.createFrom().item("Hello, world").onItem().delayIt().by(Duration.ofSeconds(5)).onItem().invoke( + COUNT::incrementAndGet); + } + + @GET + @Produces(MediaType.TEXT_PLAIN) + @Cancellable(false) + @Path("2") + public Uni uncancellableHello() { + COUNT.incrementAndGet(); + return Uni.createFrom().item("Hello, world").onItem().delayIt().by(Duration.ofSeconds(5)).onItem().invoke( + COUNT::incrementAndGet); + } + + @GET + @Produces(MediaType.TEXT_PLAIN) + @Cancellable + @Path("3") + public Uni cancellableHello() { COUNT.incrementAndGet(); return Uni.createFrom().item("Hello, world").onItem().delayIt().by(Duration.ofSeconds(5)).onItem().invoke( COUNT::incrementAndGet); diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/ResourceLocatorTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/ResourceLocatorTest.java index 2f610a82cce42..ba94a8dd38463 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/ResourceLocatorTest.java +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/ResourceLocatorTest.java @@ -1,10 +1,15 @@ package io.quarkus.resteasy.reactive.server.test.resource.basic; import static io.restassured.RestAssured.given; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import java.util.Arrays; import java.util.function.Supplier; +import jakarta.ws.rs.HttpMethod; import jakarta.ws.rs.client.Client; import jakarta.ws.rs.client.ClientBuilder; import jakarta.ws.rs.client.Entity; @@ -22,6 +27,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import io.quarkus.resteasy.reactive.server.test.resource.basic.resource.CorsPreflightResource; import io.quarkus.resteasy.reactive.server.test.resource.basic.resource.ResourceLocatorAbstractAnnotationFreeResource; import io.quarkus.resteasy.reactive.server.test.resource.basic.resource.ResourceLocatorAnnotationFreeSubResource; import io.quarkus.resteasy.reactive.server.test.resource.basic.resource.ResourceLocatorBaseResource; @@ -68,7 +74,7 @@ public JavaArchive get() { JavaArchive war = ShrinkWrap.create(JavaArchive.class); war.addClass(ResourceLocatorQueueReceiver.class).addClass(ResourceLocatorReceiver.class) .addClass(ResourceLocatorRootInterface.class).addClass(ResourceLocatorSubInterface.class) - .addClass(ResourceLocatorSubresource3Interface.class); + .addClass(ResourceLocatorSubresource3Interface.class).addClass(CorsPreflightResource.class); war.addClasses(PortProviderUtil.class, ResourceLocatorAbstractAnnotationFreeResource.class, ResourceLocatorAnnotationFreeSubResource.class, ResourceLocatorBaseResource.class, ResourceLocatorCollectionResource.class, ResourceLocatorDirectory.class, @@ -114,6 +120,48 @@ public void testSubresource() throws Exception { } } + /** + * @tpTestDetails Return custom handler for HTTP OPTIONS method in subresource redirection. The + * {@link CorsPreflightResource} instance should be returned + */ + @Test + @DisplayName("Test custom HTTP OPTIONS handler in subresource") + public void testOptionsMethodInSubresource() { + try (Response response = client.target(generateURL("/sub3/something/resources/test-options-method")).request() + .options()) { + assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); + + var customHeader = response.getHeaderString(CorsPreflightResource.TEST_PREFLIGHT_HEADER); + assertThat(customHeader, notNullValue()); + assertThat(customHeader, is("test")); + + var allowHeader = response.getHeaderString("Allow"); + assertThat(allowHeader, notNullValue()); + assertThat(Arrays.asList(allowHeader.split(", ")), + containsInAnyOrder(HttpMethod.GET, HttpMethod.POST, HttpMethod.OPTIONS, HttpMethod.HEAD)); + } + } + + /** + * @tpTestDetails Custom handler for HTTP OPTIONS method in subresource. + */ + @Test + @DisplayName("Test custom explicit HTTP OPTIONS handler in subresource") + public void testOptionsMethodExplicitInSubresource() { + try (Response response = client.target(generateURL("/sub3/something/resources/test-options-method-explicit")).request() + .options()) { + assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); + + var customHeader = response.getHeaderString(ResourceLocatorSubresource2.TEST_PREFLIGHT_HEADER); + assertThat(customHeader, notNullValue()); + assertThat(customHeader, is("test")); + + var allowHeader = response.getHeaderString("Allow"); + assertThat(allowHeader, notNullValue()); + assertThat(Arrays.asList(allowHeader.split(", ")), containsInAnyOrder(HttpMethod.GET)); + } + } + /** * @tpTestDetails Two matching methods, one a resource locator, the other a resource method. * @tpSince RESTEasy 3.0.20 diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/CorsPreflightResource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/CorsPreflightResource.java new file mode 100644 index 0000000000000..d1b56b49d77fa --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/CorsPreflightResource.java @@ -0,0 +1,17 @@ +package io.quarkus.resteasy.reactive.server.test.resource.basic.resource; + +import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.OPTIONS; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +public class CorsPreflightResource { + public static final String TEST_PREFLIGHT_HEADER = "preflight-header-test"; + + @Path("{any:.*}") + @OPTIONS + public Response preflight() { + return Response.ok().allow(HttpMethod.GET, HttpMethod.POST, HttpMethod.OPTIONS, HttpMethod.HEAD) + .header(TEST_PREFLIGHT_HEADER, "test").build(); + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/ResourceLocatorBaseResource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/ResourceLocatorBaseResource.java index 308a4a738be87..ab09e88f1643c 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/ResourceLocatorBaseResource.java +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/ResourceLocatorBaseResource.java @@ -6,6 +6,7 @@ import java.util.List; import jakarta.ws.rs.GET; +import jakarta.ws.rs.OPTIONS; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; @@ -61,4 +62,10 @@ public ResourceLocatorSubresource getSubresource() { return new ResourceLocatorSubresource(); } + @OPTIONS + @Path("{any:.*}") + public Object preflight() { + return "Here might be a custom handler for HTTP OPTIONS method"; + } + } diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/ResourceLocatorSubresource.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/ResourceLocatorSubresource.java index dfb0d717642ef..58261ac763c3b 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/ResourceLocatorSubresource.java +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/ResourceLocatorSubresource.java @@ -4,6 +4,7 @@ import jakarta.ws.rs.BeanParam; import jakarta.ws.rs.GET; +import jakarta.ws.rs.HttpMethod; import jakarta.ws.rs.Path; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Context; @@ -13,6 +14,8 @@ import org.jboss.resteasy.reactive.RestPath; import org.junit.jupiter.api.Assertions; +import io.vertx.core.http.HttpServerRequest; + public class ResourceLocatorSubresource { private static final Logger LOG = Logger.getLogger(ResourceLocatorSubresource.class); @@ -62,6 +65,20 @@ public String getValueFromBeanParam(@BeanParam Params params) { return params.param + " and " + params.value; } + @Path("/test-options-method-explicit") + public Object testOptionsMethodExplicit() { + return new ResourceLocatorSubresource2(); + } + + @Path("/test-options-method") + public Object testOptionsMethod(@Context HttpServerRequest request) { + if (request.method().name().equals(HttpMethod.OPTIONS)) { + return new CorsPreflightResource(); + } + + return "Should be used only with HTTP @OPTIONS method"; + } + public static class Params { @RestPath String param; diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/ResourceLocatorSubresource2.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/ResourceLocatorSubresource2.java index da745c63579ec..677ba369a33ec 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/ResourceLocatorSubresource2.java +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/resource/basic/resource/ResourceLocatorSubresource2.java @@ -1,16 +1,19 @@ package io.quarkus.resteasy.reactive.server.test.resource.basic.resource; import jakarta.ws.rs.GET; +import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.OPTIONS; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; import org.jboss.logging.Logger; import org.junit.jupiter.api.Assertions; public class ResourceLocatorSubresource2 { - + public static final String TEST_PREFLIGHT_HEADER = "test-preflight-header"; private static final Logger LOG = Logger.getLogger(ResourceLocatorSubresource2.class); @GET @@ -35,4 +38,10 @@ public String doGet(@PathParam("param") String param, @Context UriInfo uri) { Assertions.assertEquals("2", param); return this.getClass().getName() + "-" + param; } + + @OPTIONS + @Path("{any:.*}") + public Response preflight() { + return Response.ok().allow(HttpMethod.GET).header(TEST_PREFLIGHT_HEADER, "test").build(); + } } diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AbstractCustomExceptionMapperTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AbstractCustomExceptionMapperTest.java new file mode 100644 index 0000000000000..a553cd1823209 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AbstractCustomExceptionMapperTest.java @@ -0,0 +1,157 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import java.util.Map; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; + +import org.hamcrest.Matchers; +import org.jboss.resteasy.reactive.server.ServerExceptionMapper; +import org.junit.jupiter.api.Test; + +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.SecurityIdentityAugmentor; +import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; +import io.restassured.RestAssured; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +/** + * Tests internal server errors and other custom exceptions raised during + * proactive authentication can be handled by the exception mappers. + * For lazy authentication, it is important that these exceptions raised during authentication + * required by HTTP permissions are also propagated. + */ +public abstract class AbstractCustomExceptionMapperTest { + + @Test + public void testNoExceptions() { + RestAssured.given() + .auth().preemptive().basic("gaston", "gaston-password") + .get("/hello") + .then() + .statusCode(200) + .body(Matchers.is("Hello Gaston")); + RestAssured.given() + .get("/hello") + .then() + .statusCode(401); + } + + @Test + public void testUnhandledRuntimeException() { + RestAssured.given() + .auth().preemptive().basic("gaston", "gaston-password") + .header("fail-unhandled", "true") + .get("/hello") + .then() + .statusCode(500) + .body(Matchers.containsString(UnhandledRuntimeException.class.getName())) + .body(Matchers.containsString("Expected unhandled failure")); + } + + @Test + public void testCustomExceptionInIdentityProvider() { + RestAssured.given() + .auth().preemptive().basic("gaston", "gaston-password") + .header("fail-authentication", "true") + .get("/hello") + .then() + .statusCode(500) + .body(Matchers.is("Expected authentication failure")); + } + + @Test + public void testCustomExceptionInIdentityAugmentor() { + RestAssured.given() + .auth().preemptive().basic("gaston", "gaston-password") + .header("fail-augmentation", "true") + .get("/hello") + .then() + .statusCode(500) + .body(Matchers.is("Expected identity augmentation failure")); + } + + @Path("/hello") + public static class HelloResource { + @GET + public String hello(@Context SecurityContext context) { + var principalName = context.getUserPrincipal() == null ? "" : " " + context.getUserPrincipal().getName(); + return "Hello" + principalName; + } + } + + public static class Mappers { + @ServerExceptionMapper(CustomRuntimeException.class) + public Response toResponse(CustomRuntimeException exception) { + return Response.serverError().entity(exception.getMessage()).build(); + } + } + + @ApplicationScoped + public static class CustomIdentityAugmentor implements SecurityIdentityAugmentor { + @Override + public Uni augment(SecurityIdentity securityIdentity, + AuthenticationRequestContext authenticationRequestContext) { + return augment(securityIdentity, authenticationRequestContext, Map.of()); + } + + @Override + public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context, + Map attributes) { + final RoutingContext routingContext = HttpSecurityUtils.getRoutingContextAttribute(attributes); + if (routingContext.request().headers().contains("fail-augmentation")) { + return Uni.createFrom().failure(new CustomRuntimeException("Expected identity augmentation failure")); + } + return Uni.createFrom().item(identity); + } + } + + public static class CustomRuntimeException extends RuntimeException { + public CustomRuntimeException(String message) { + super(message); + } + } + + public static class UnhandledRuntimeException extends RuntimeException { + public UnhandledRuntimeException(String message) { + super(message); + } + } + + @ApplicationScoped + public static class BasicIdentityProvider implements IdentityProvider { + + @Override + public Class getRequestType() { + return UsernamePasswordAuthenticationRequest.class; + } + + @Override + public Uni authenticate(UsernamePasswordAuthenticationRequest authRequest, + AuthenticationRequestContext authRequestCtx) { + if (!"gaston".equals(authRequest.getUsername())) { + return Uni.createFrom().nullItem(); + } + + final RoutingContext routingContext = HttpSecurityUtils.getRoutingContextAttribute(authRequest); + if (routingContext.request().headers().contains("fail-authentication")) { + return Uni.createFrom().failure(new CustomRuntimeException("Expected authentication failure")); + } + if (routingContext.request().headers().contains("fail-unhandled")) { + return Uni.createFrom().failure(new UnhandledRuntimeException("Expected unhandled failure")); + } + return Uni.createFrom() + .item(QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal("Gaston")).build()); + } + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/LazyAuthCustomExceptionMapperTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/LazyAuthCustomExceptionMapperTest.java new file mode 100644 index 0000000000000..c57a3eeabbbe9 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/LazyAuthCustomExceptionMapperTest.java @@ -0,0 +1,18 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class LazyAuthCustomExceptionMapperTest extends AbstractCustomExceptionMapperTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest().withApplicationRoot(jar -> jar + .addAsResource(new StringAsset(""" + quarkus.http.auth.permission.authentication.paths=* + quarkus.http.auth.permission.authentication.policy=authenticated + quarkus.http.auth.proactive=false + """), "application.properties")); + +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/ProactiveAuthCustomExceptionMapperTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/ProactiveAuthCustomExceptionMapperTest.java new file mode 100644 index 0000000000000..afe82d74700a0 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/ProactiveAuthCustomExceptionMapperTest.java @@ -0,0 +1,17 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class ProactiveAuthCustomExceptionMapperTest extends AbstractCustomExceptionMapperTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest().withApplicationRoot(jar -> jar + .addAsResource(new StringAsset(""" + quarkus.http.auth.permission.authentication.paths=* + quarkus.http.auth.permission.authentication.policy=authenticated + """), "application.properties")); + +} diff --git a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/ResteasyReactiveRecorder.java b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/ResteasyReactiveRecorder.java index 0d28c5159b7ed..9b438d2452ccd 100644 --- a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/ResteasyReactiveRecorder.java +++ b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/ResteasyReactiveRecorder.java @@ -1,6 +1,9 @@ package io.quarkus.resteasy.reactive.server.runtime; import static io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.DefaultAuthFailureHandler.extractRootCause; +import static io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.DefaultAuthFailureHandler.isOtherAuthenticationFailure; +import static io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.DefaultAuthFailureHandler.markIfOtherAuthenticationFailure; +import static io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.DefaultAuthFailureHandler.removeMarkAsOtherAuthenticationFailure; import java.io.Closeable; import java.lang.reflect.InvocationTargetException; @@ -66,6 +69,7 @@ import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.AuthenticationRedirectException; import io.quarkus.security.ForbiddenException; +import io.quarkus.security.UnauthorizedException; import io.quarkus.security.identity.CurrentIdentityAssociation; import io.quarkus.vertx.http.runtime.CurrentVertxRequest; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; @@ -231,9 +235,15 @@ public void handle(RoutingContext event) { } } - if (event.failure() instanceof AuthenticationException - || event.failure() instanceof ForbiddenException) { - restInitialHandler.beginProcessing(event, event.failure()); + final Throwable failure = event.failure(); + final boolean isOtherAuthFailure = isOtherAuthenticationFailure(event) + && isFailureHandledByExceptionMappers(failure); + if (isOtherAuthFailure) { + removeMarkAsOtherAuthenticationFailure(event); + restInitialHandler.beginProcessing(event, failure); + } else if (failure instanceof AuthenticationException + || failure instanceof UnauthorizedException || failure instanceof ForbiddenException) { + restInitialHandler.beginProcessing(event, failure); } else { event.next(); } @@ -241,6 +251,11 @@ public void handle(RoutingContext event) { }; } + private boolean isFailureHandledByExceptionMappers(Throwable throwable) { + return currentDeployment != null + && currentDeployment.getExceptionMapper().getExceptionMapper(throwable.getClass(), null, null) != null; + } + /** * This is Quarkus specific. *

@@ -420,6 +435,7 @@ private static final class FailingDefaultAuthFailureHandler implements BiConsume @Override public void accept(RoutingContext event, Throwable throwable) { + markIfOtherAuthenticationFailure(event, throwable); if (!event.failed()) { event.fail(extractRootCause(throwable)); } diff --git a/extensions/resteasy-reactive/rest/spi-deployment/src/main/java/io/quarkus/resteasy/reactive/server/spi/GlobalHandlerCustomizerBuildItem.java b/extensions/resteasy-reactive/rest/spi-deployment/src/main/java/io/quarkus/resteasy/reactive/server/spi/GlobalHandlerCustomizerBuildItem.java new file mode 100644 index 0000000000000..851a390b1987c --- /dev/null +++ b/extensions/resteasy-reactive/rest/spi-deployment/src/main/java/io/quarkus/resteasy/reactive/server/spi/GlobalHandlerCustomizerBuildItem.java @@ -0,0 +1,22 @@ +package io.quarkus.resteasy.reactive.server.spi; + +import org.jboss.resteasy.reactive.server.model.HandlerChainCustomizer; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * Allows for extension to register global handler customizers. + * These are useful for adding handlers that run before and after pre matching + */ +public final class GlobalHandlerCustomizerBuildItem extends MultiBuildItem { + + private final HandlerChainCustomizer customizer; + + public GlobalHandlerCustomizerBuildItem(HandlerChainCustomizer customizer) { + this.customizer = customizer; + } + + public HandlerChainCustomizer getCustomizer() { + return customizer; + } +} diff --git a/extensions/scheduler/common/src/main/java/io/quarkus/scheduler/common/runtime/util/SchedulerUtils.java b/extensions/scheduler/common/src/main/java/io/quarkus/scheduler/common/runtime/util/SchedulerUtils.java index 96b87a5e33ce3..289b9beef6876 100644 --- a/extensions/scheduler/common/src/main/java/io/quarkus/scheduler/common/runtime/util/SchedulerUtils.java +++ b/extensions/scheduler/common/src/main/java/io/quarkus/scheduler/common/runtime/util/SchedulerUtils.java @@ -14,7 +14,7 @@ import jakarta.enterprise.inject.Instance; import org.eclipse.microprofile.config.Config; -import org.eclipse.microprofile.config.spi.ConfigProviderResolver; +import org.eclipse.microprofile.config.ConfigProvider; import io.quarkus.arc.Arc; import io.quarkus.runtime.configuration.DurationConverter; @@ -147,9 +147,7 @@ private static String adjustExpressionSyntax(String val) { * Adapted from {@link io.smallrye.config.ExpressionConfigSourceInterceptor} */ private static String resolvePropertyExpression(String expr) { - // Force the runtime CL in order to make the DEV UI page work - final ClassLoader cl = SchedulerUtils.class.getClassLoader(); - final Config config = ConfigProviderResolver.instance().getConfig(cl); + final Config config = ConfigProvider.getConfig(); final Expression expression = Expression.compile(expr, LENIENT_SYNTAX, NO_TRIM); final String expanded = expression.evaluate(new BiConsumer, StringBuilder>() { @Override diff --git a/extensions/security-webauthn/deployment/pom.xml b/extensions/security-webauthn/deployment/pom.xml index bf0f0d74fe732..dd286d827b5ce 100644 --- a/extensions/security-webauthn/deployment/pom.xml +++ b/extensions/security-webauthn/deployment/pom.xml @@ -1,6 +1,6 @@ - + 4.0.0 io.quarkus diff --git a/extensions/security-webauthn/deployment/src/main/java/io/quarkus/security/webauthn/deployment/QuarkusSecurityWebAuthnProcessor.java b/extensions/security-webauthn/deployment/src/main/java/io/quarkus/security/webauthn/deployment/QuarkusSecurityWebAuthnProcessor.java index 9a12d8e4e9f55..a624fdf872752 100644 --- a/extensions/security-webauthn/deployment/src/main/java/io/quarkus/security/webauthn/deployment/QuarkusSecurityWebAuthnProcessor.java +++ b/extensions/security-webauthn/deployment/src/main/java/io/quarkus/security/webauthn/deployment/QuarkusSecurityWebAuthnProcessor.java @@ -7,6 +7,20 @@ import org.jboss.jandex.DotName; +import com.webauthn4j.data.AuthenticationRequest; +import com.webauthn4j.data.AuthenticatorAssertionResponse; +import com.webauthn4j.data.AuthenticatorAttestationResponse; +import com.webauthn4j.data.PublicKeyCredential; +import com.webauthn4j.data.PublicKeyCredentialCreationOptions; +import com.webauthn4j.data.PublicKeyCredentialParameters; +import com.webauthn4j.data.PublicKeyCredentialRequestOptions; +import com.webauthn4j.data.PublicKeyCredentialRpEntity; +import com.webauthn4j.data.PublicKeyCredentialType; +import com.webauthn4j.data.PublicKeyCredentialUserEntity; +import com.webauthn4j.data.RegistrationRequest; +import com.webauthn4j.data.attestation.AttestationObject; +import com.webauthn4j.data.client.CollectedClientData; + import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; @@ -15,12 +29,12 @@ import io.quarkus.deployment.annotations.BuildSteps; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; -import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; +import io.quarkus.deployment.builditem.IndexDependencyBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyBuildItem; import io.quarkus.security.webauthn.WebAuthn; import io.quarkus.security.webauthn.WebAuthnAuthenticationMechanism; import io.quarkus.security.webauthn.WebAuthnAuthenticatorStorage; import io.quarkus.security.webauthn.WebAuthnBuildTimeConfig; -import io.quarkus.security.webauthn.WebAuthnIdentityProvider; import io.quarkus.security.webauthn.WebAuthnRecorder; import io.quarkus.security.webauthn.WebAuthnSecurity; import io.quarkus.security.webauthn.WebAuthnTrustedIdentityProvider; @@ -28,18 +42,50 @@ import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; import io.quarkus.vertx.http.deployment.VertxWebRouterBuildItem; import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; -import io.vertx.ext.auth.webauthn.impl.attestation.Attestation; @BuildSteps(onlyIf = QuarkusSecurityWebAuthnProcessor.IsEnabled.class) class QuarkusSecurityWebAuthnProcessor { + @BuildStep + public IndexDependencyBuildItem addTypesToJandex() { + // needed by registerJacksonTypes() + return new IndexDependencyBuildItem("com.webauthn4j", "webauthn4j-core"); + } + + @BuildStep + public void registerJacksonTypes(BuildProducer reflection) { + reflection.produce( + ReflectiveHierarchyBuildItem.builder(AuthenticatorAssertionResponse.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(AuthenticatorAttestationResponse.class).build()); + reflection.produce(ReflectiveHierarchyBuildItem.builder(AuthenticationRequest.class).build()); + reflection.produce(ReflectiveHierarchyBuildItem.builder(RegistrationRequest.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredentialCreationOptions.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredentialRequestOptions.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredentialRpEntity.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredentialUserEntity.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredentialParameters.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredentialType.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredential.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(AttestationObject.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(CollectedClientData.class).build()); + } + @BuildStep public void myBeans(BuildProducer additionalBeans) { AdditionalBeanBuildItem.Builder builder = AdditionalBeanBuildItem.builder().setUnremovable(); builder.addBeanClass(WebAuthnSecurity.class) .addBeanClass(WebAuthnAuthenticatorStorage.class) - .addBeanClass(WebAuthnIdentityProvider.class) .addBeanClass(WebAuthnTrustedIdentityProvider.class); additionalBeans.produce(builder.build()); } @@ -55,11 +101,6 @@ public void setup( nonApplicationRootPathBuildItem.getNonApplicationRootPath()); } - @BuildStep - public ServiceProviderBuildItem serviceLoader() { - return ServiceProviderBuildItem.allProvidersFromClassPath(Attestation.class.getName()); - } - @BuildStep @Record(ExecutionTime.RUNTIME_INIT) SyntheticBeanBuildItem initWebAuthnAuth( diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/ManualResource.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/ManualResource.java index 8d2f628d426b4..0eb35dfe100d9 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/ManualResource.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/ManualResource.java @@ -4,6 +4,7 @@ import jakarta.ws.rs.BeanParam; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; import io.quarkus.security.webauthn.WebAuthnLoginResponse; import io.quarkus.security.webauthn.WebAuthnRegisterResponse; @@ -23,22 +24,23 @@ public class ManualResource { @Path("register") @POST - public Uni register(@BeanParam WebAuthnRegisterResponse register, RoutingContext ctx) { - return security.register(register, ctx).map(authenticator -> { + public Uni register(@QueryParam("username") String username, @BeanParam WebAuthnRegisterResponse register, + RoutingContext ctx) { + return security.register(username, register, ctx).map(authenticator -> { // need to attach the authenticator to the user - userProvider.store(authenticator); - security.rememberUser(authenticator.getUserName(), ctx); + userProvider.reallyStore(authenticator); + security.rememberUser(authenticator.getUsername(), ctx); return "OK"; }); } @Path("login") @POST - public Uni register(@BeanParam WebAuthnLoginResponse login, RoutingContext ctx) { + public Uni login(@BeanParam WebAuthnLoginResponse login, RoutingContext ctx) { return security.login(login, ctx).map(authenticator -> { // need to update the user's authenticator - userProvider.update(authenticator.getUserName(), authenticator.getCredID(), authenticator.getCounter()); - security.rememberUser(authenticator.getUserName(), ctx); + userProvider.reallyUpdate(authenticator.getCredentialID(), authenticator.getCounter()); + security.rememberUser(authenticator.getUsername(), ctx); return "OK"; }); } diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/TestResource.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/TestResource.java index f1415255f31c8..90160a8bb38fc 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/TestResource.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/TestResource.java @@ -16,7 +16,7 @@ public class TestResource { @Authenticated @Path("secure") @GET - public String getUserName() { + public String getUsername() { return identity.getPrincipal().getName() + ": " + identity.getRoles(); } diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAndBasicAuthnTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAndBasicAuthnTest.java index ced7d44860ff1..1e9bc68a63ef3 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAndBasicAuthnTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAndBasicAuthnTest.java @@ -1,5 +1,6 @@ package io.quarkus.security.webauthn.test; +import java.net.URL; import java.util.List; import jakarta.inject.Inject; @@ -13,9 +14,11 @@ import io.quarkus.security.test.utils.TestIdentityController; import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnRunTimeConfig; import io.quarkus.security.webauthn.WebAuthnUserProvider; import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; @@ -24,7 +27,6 @@ import io.restassured.specification.RequestSpecification; import io.smallrye.config.SmallRyeConfigBuilder; import io.vertx.core.json.JsonObject; -import io.vertx.ext.auth.webauthn.Authenticator; public class WebAuthnAndBasicAuthnTest { @@ -40,6 +42,9 @@ public class WebAuthnAndBasicAuthnTest { @Inject WebAuthnUserProvider userProvider; + @TestHTTPResource + URL url; + @BeforeAll public static void setupUsers() { TestIdentityController.resetRoles() @@ -50,10 +55,10 @@ public static void setupUsers() { @Test public void test() throws Exception { - Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stev").await().indefinitely().isEmpty()); + Assertions.assertTrue(userProvider.findByUsername("stev").await().indefinitely().isEmpty()); CookieFilter cookieFilter = new CookieFilter(); - String challenge = WebAuthnEndpointHelper.invokeRegistration("stev", cookieFilter); - WebAuthnHardware hardwareKey = new WebAuthnHardware(); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stev", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); JsonObject registration = hardwareKey.makeRegistrationJson(challenge); // now finalise @@ -66,17 +71,17 @@ public void test() throws Exception { .build() .getConfigMapping(WebAuthnRunTimeConfig.class); request + .queryParam("username", "stev") .post("/register") .then().statusCode(200) .body(Matchers.is("OK")) .cookie(config.challengeCookieName(), Matchers.is("")) - .cookie(config.challengeUsernameCookieName(), Matchers.is("")) .cookie("quarkus-credential", Matchers.notNullValue()); // make sure we stored the user - List users = userProvider.findWebAuthnCredentialsByUserName("stev").await().indefinitely(); + List users = userProvider.findByUsername("stev").await().indefinitely(); Assertions.assertEquals(1, users.size()); - Assertions.assertTrue(users.get(0).getUserName().equals("stev")); + Assertions.assertTrue(users.get(0).getUsername().equals("stev")); Assertions.assertEquals(1, users.get(0).getCounter()); // make sure our login cookie works diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticBlockingTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticBlockingTest.java index 1a48817c00263..2381effb59ecf 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticBlockingTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticBlockingTest.java @@ -1,5 +1,6 @@ package io.quarkus.security.webauthn.test; +import org.jboss.shrinkwrap.api.asset.StringAsset; import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.test.QuarkusUnitTest; @@ -11,6 +12,10 @@ public class WebAuthnAutomaticBlockingTest extends WebAuthnAutomaticTest { @RegisterExtension static final QuarkusUnitTest config = new QuarkusUnitTest() .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset(""" + quarkus.webauthn.enable-login-endpoint=true + quarkus.webauthn.enable-registration-endpoint=true + """), "application.properties") .addClasses(WebAuthnBlockingTestUserProvider.class, WebAuthnTestUserProvider.class, WebAuthnHardware.class, TestResource.class, TestUtil.class)); } diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticNonBlockingTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticNonBlockingTest.java index 8c56262608a26..485144730881c 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticNonBlockingTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticNonBlockingTest.java @@ -1,5 +1,6 @@ package io.quarkus.security.webauthn.test; +import org.jboss.shrinkwrap.api.asset.StringAsset; import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.test.QuarkusUnitTest; @@ -11,6 +12,10 @@ public class WebAuthnAutomaticNonBlockingTest extends WebAuthnAutomaticTest { @RegisterExtension static final QuarkusUnitTest config = new QuarkusUnitTest() .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset(""" + quarkus.webauthn.enable-login-endpoint=true + quarkus.webauthn.enable-registration-endpoint=true + """), "application.properties") .addClasses(WebAuthnNonBlockingTestUserProvider.class, WebAuthnTestUserProvider.class, WebAuthnHardware.class, TestResource.class, TestUtil.class)); diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticTest.java index 696b1bb5481a7..b64600f2649ca 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticTest.java @@ -1,5 +1,6 @@ package io.quarkus.security.webauthn.test; +import java.net.URL; import java.util.List; import jakarta.inject.Inject; @@ -8,19 +9,23 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; import io.restassured.RestAssured; import io.restassured.filter.cookie.CookieFilter; import io.vertx.core.json.JsonObject; -import io.vertx.ext.auth.webauthn.Authenticator; public abstract class WebAuthnAutomaticTest { @Inject WebAuthnUserProvider userProvider; + @TestHTTPResource + URL url; + @Test public void test() throws Exception { @@ -35,19 +40,19 @@ public void test() throws Exception { .given().redirects().follow(false) .get("/cheese").then().statusCode(302); - Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely().isEmpty()); + Assertions.assertTrue(userProvider.findByUsername("stef").await().indefinitely().isEmpty()); CookieFilter cookieFilter = new CookieFilter(); - WebAuthnHardware hardwareKey = new WebAuthnHardware(); - String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter); JsonObject registration = hardwareKey.makeRegistrationJson(challenge); // now finalise - WebAuthnEndpointHelper.invokeCallback(registration, cookieFilter); + WebAuthnEndpointHelper.invokeRegistration("stef", registration, cookieFilter); // make sure we stored the user - List users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + List users = userProvider.findByUsername("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); - Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertTrue(users.get(0).getUsername().equals("stef")); Assertions.assertEquals(1, users.get(0).getCounter()); // make sure our login cookie works @@ -56,20 +61,38 @@ public void test() throws Exception { // reset cookies for the login phase cookieFilter = new CookieFilter(); // now try to log in - challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", cookieFilter); JsonObject login = hardwareKey.makeLoginJson(challenge); // now finalise - WebAuthnEndpointHelper.invokeCallback(login, cookieFilter); + WebAuthnEndpointHelper.invokeLogin(login, cookieFilter); // make sure we bumped the user - users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + users = userProvider.findByUsername("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); - Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertTrue(users.get(0).getUsername().equals("stef")); Assertions.assertEquals(2, users.get(0).getCounter()); // make sure our login cookie still works checkLoggedIn(cookieFilter); + + // reset cookies for a new login + cookieFilter = new CookieFilter(); + // now try to log in without a username + challenge = WebAuthnEndpointHelper.obtainLoginChallenge(null, cookieFilter); + login = hardwareKey.makeLoginJson(challenge); + + // now finalise + WebAuthnEndpointHelper.invokeLogin(login, cookieFilter); + + // make sure we bumped the user + users = userProvider.findByUsername("stef").await().indefinitely(); + Assertions.assertEquals(1, users.size()); + Assertions.assertTrue(users.get(0).getUsername().equals("stef")); + Assertions.assertEquals(3, users.get(0).getCounter()); + + // make sure our login cookie still works + checkLoggedIn(cookieFilter); } private void checkLoggedIn(CookieFilter cookieFilter) { diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnBlockingTestUserProvider.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnBlockingTestUserProvider.java index de755e7cd41be..49786959dff82 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnBlockingTestUserProvider.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnBlockingTestUserProvider.java @@ -6,10 +6,10 @@ import org.jboss.resteasy.reactive.server.core.BlockingOperationSupport; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; import io.smallrye.common.annotation.Blocking; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; /** * This UserProvider stores and updates the credentials in the callback endpoint, but is blocking @@ -18,21 +18,27 @@ @Blocking public class WebAuthnBlockingTestUserProvider extends WebAuthnTestUserProvider { @Override - public Uni> findWebAuthnCredentialsByCredID(String credId) { + public Uni findByCredentialId(String credId) { assertBlockingAllowed(); - return super.findWebAuthnCredentialsByCredID(credId); + return super.findByCredentialId(credId); } @Override - public Uni> findWebAuthnCredentialsByUserName(String userId) { + public Uni> findByUsername(String userId) { assertBlockingAllowed(); - return super.findWebAuthnCredentialsByUserName(userId); + return super.findByUsername(userId); } @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { + public Uni update(String credentialId, long counter) { assertBlockingAllowed(); - return super.updateOrStoreWebAuthnCredentials(authenticator); + return super.update(credentialId, counter); + } + + @Override + public Uni store(WebAuthnCredentialRecord credentialRecord) { + assertBlockingAllowed(); + return super.store(credentialRecord); } private void assertBlockingAllowed() { diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualCustomCookiesTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualCustomCookiesTest.java index 47489fae56e8d..d2b0ef0f4643d 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualCustomCookiesTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualCustomCookiesTest.java @@ -1,5 +1,6 @@ package io.quarkus.security.webauthn.test; +import java.net.URL; import java.util.List; import jakarta.inject.Inject; @@ -10,8 +11,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnUserProvider; import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; @@ -19,7 +22,6 @@ import io.restassured.filter.cookie.CookieFilter; import io.restassured.specification.RequestSpecification; import io.vertx.core.json.JsonObject; -import io.vertx.ext.auth.webauthn.Authenticator; /** * Same test as WebAuthnManualTest but with custom cookies configured @@ -38,6 +40,9 @@ public class WebAuthnManualCustomCookiesTest { @Inject WebAuthnUserProvider userProvider; + @TestHTTPResource + URL url; + @Test public void test() throws Exception { @@ -52,10 +57,10 @@ public void test() throws Exception { .given().redirects().follow(false) .get("/cheese").then().statusCode(302); - Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely().isEmpty()); + Assertions.assertTrue(userProvider.findByUsername("stef").await().indefinitely().isEmpty()); CookieFilter cookieFilter = new CookieFilter(); - String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); - WebAuthnHardware hardwareKey = new WebAuthnHardware(); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); JsonObject registration = hardwareKey.makeRegistrationJson(challenge); // now finalise @@ -64,17 +69,17 @@ public void test() throws Exception { .filter(cookieFilter); WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registration); request + .queryParam("username", "stef") .post("/register") .then().statusCode(200) .body(Matchers.is("OK")) .cookie("challenge-cookie", Matchers.is("")) - .cookie("username-cookie", Matchers.is("")) .cookie("main-cookie", Matchers.notNullValue()); // make sure we stored the user - List users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + List users = userProvider.findByUsername("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); - Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertTrue(users.get(0).getUsername().equals("stef")); Assertions.assertEquals(1, users.get(0).getCounter()); // make sure our login cookie works @@ -83,7 +88,7 @@ public void test() throws Exception { // reset cookies for the login phase cookieFilter = new CookieFilter(); // now try to log in - challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", cookieFilter); JsonObject login = hardwareKey.makeLoginJson(challenge); // now finalise @@ -96,13 +101,12 @@ public void test() throws Exception { .then().statusCode(200) .body(Matchers.is("OK")) .cookie("challenge-cookie", Matchers.is("")) - .cookie("username-cookie", Matchers.is("")) .cookie("main-cookie", Matchers.notNullValue()); // make sure we bumped the user - users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + users = userProvider.findByUsername("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); - Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertTrue(users.get(0).getUsername().equals("stef")); Assertions.assertEquals(2, users.get(0).getCounter()); // make sure our login cookie still works diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTest.java index be602ec2aa4c6..e54ae25e2a92f 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTest.java @@ -1,16 +1,19 @@ package io.quarkus.security.webauthn.test; +import java.net.URL; import java.util.List; import jakarta.inject.Inject; import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; @@ -18,18 +21,26 @@ import io.restassured.filter.cookie.CookieFilter; import io.restassured.specification.RequestSpecification; import io.vertx.core.json.JsonObject; -import io.vertx.ext.auth.webauthn.Authenticator; public class WebAuthnManualTest { @RegisterExtension static final QuarkusUnitTest config = new QuarkusUnitTest() .withApplicationRoot((jar) -> jar - .addClasses(WebAuthnManualTestUserProvider.class, WebAuthnTestUserProvider.class, WebAuthnHardware.class, + .addClasses(WebAuthnManualTestUserProvider.class, WebAuthnTestUserProvider.class, + WebAuthnTestUserProvider.class, WebAuthnHardware.class, TestResource.class, ManualResource.class, TestUtil.class)); @Inject - WebAuthnUserProvider userProvider; + WebAuthnManualTestUserProvider userProvider; + + @TestHTTPResource + URL url; + + @BeforeEach + public void before() { + userProvider.clear(); + } @Test public void test() throws Exception { @@ -45,10 +56,10 @@ public void test() throws Exception { .given().redirects().follow(false) .get("/cheese").then().statusCode(302); - Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely().isEmpty()); + Assertions.assertTrue(userProvider.findByUsername("stef").await().indefinitely().isEmpty()); CookieFilter cookieFilter = new CookieFilter(); - String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); - WebAuthnHardware hardwareKey = new WebAuthnHardware(); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); JsonObject registration = hardwareKey.makeRegistrationJson(challenge); // now finalise @@ -57,17 +68,19 @@ public void test() throws Exception { .filter(cookieFilter); WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registration); request + .log().ifValidationFails() + .queryParam("username", "stef") .post("/register") .then().statusCode(200) + .log().ifValidationFails() .body(Matchers.is("OK")) .cookie("_quarkus_webauthn_challenge", Matchers.is("")) - .cookie("_quarkus_webauthn_username", Matchers.is("")) .cookie("quarkus-credential", Matchers.notNullValue()); // make sure we stored the user - List users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + List users = userProvider.findByUsername("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); - Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertTrue(users.get(0).getUsername().equals("stef")); Assertions.assertEquals(1, users.get(0).getCounter()); // make sure our login cookie works @@ -76,7 +89,7 @@ public void test() throws Exception { // reset cookies for the login phase cookieFilter = new CookieFilter(); // now try to log in - challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", cookieFilter); JsonObject login = hardwareKey.makeLoginJson(challenge); // now finalise @@ -85,21 +98,55 @@ public void test() throws Exception { .filter(cookieFilter); WebAuthnEndpointHelper.addWebAuthnLoginFormParameters(request, login); request + .log().ifValidationFails() .post("/login") .then().statusCode(200) + .log().ifValidationFails() .body(Matchers.is("OK")) .cookie("_quarkus_webauthn_challenge", Matchers.is("")) - .cookie("_quarkus_webauthn_username", Matchers.is("")) .cookie("quarkus-credential", Matchers.notNullValue()); // make sure we bumped the user - users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + users = userProvider.findByUsername("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); - Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertTrue(users.get(0).getUsername().equals("stef")); Assertions.assertEquals(2, users.get(0).getCounter()); // make sure our login cookie still works checkLoggedIn(cookieFilter); + + // make sure we can't log in via the default endpoint + // reset cookies for the login phase + CookieFilter finalCookieFilter = new CookieFilter(); + // now try to log in + challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", finalCookieFilter); + JsonObject defaultLogin = hardwareKey.makeLoginJson(challenge); + + // now finalise + Assertions.assertThrows(AssertionError.class, + () -> WebAuthnEndpointHelper.invokeLogin(defaultLogin, finalCookieFilter)); + + // make sure we did not bump the user + users = userProvider.findByUsername("stef").await().indefinitely(); + Assertions.assertEquals(1, users.size()); + Assertions.assertTrue(users.get(0).getUsername().equals("stef")); + Assertions.assertEquals(2, users.get(0).getCounter()); + } + + @Test + public void checkDefaultRegistrationDisabled() { + Assertions.assertTrue(userProvider.findByUsername("stef").await().indefinitely().isEmpty()); + CookieFilter cookieFilter = new CookieFilter(); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter); + JsonObject registration = hardwareKey.makeRegistrationJson(challenge); + + // now finalise + Assertions.assertThrows(AssertionError.class, + () -> WebAuthnEndpointHelper.invokeRegistration("stef", registration, cookieFilter)); + + // make sure we did not create any user + Assertions.assertTrue(userProvider.findByUsername("stef").await().indefinitely().isEmpty()); } private void checkLoggedIn(CookieFilter cookieFilter) { diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTestUserProvider.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTestUserProvider.java index 65ae0801fdd95..86d561d3321d1 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTestUserProvider.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTestUserProvider.java @@ -5,10 +5,10 @@ import jakarta.enterprise.context.ApplicationScoped; import io.quarkus.arc.Arc; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnSecurity; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; /** * This UserProvider does not update or store credentials in the callback endpoint: you do it manually after calls to @@ -19,21 +19,15 @@ public class WebAuthnManualTestUserProvider extends WebAuthnTestUserProvider { @Override - public Uni> findWebAuthnCredentialsByCredID(String credId) { + public Uni findByCredentialId(String credId) { assertRequestContext(); - return super.findWebAuthnCredentialsByCredID(credId); + return super.findByCredentialId(credId); } @Override - public Uni> findWebAuthnCredentialsByUserName(String userId) { + public Uni> findByUsername(String userId) { assertRequestContext(); - return super.findWebAuthnCredentialsByUserName(userId); - } - - @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { - assertRequestContext(); - return Uni.createFrom().nullItem(); + return super.findByUsername(userId); } private void assertRequestContext() { diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnNonBlockingTestUserProvider.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnNonBlockingTestUserProvider.java index 1ce44c088ed52..7d88c1c620187 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnNonBlockingTestUserProvider.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnNonBlockingTestUserProvider.java @@ -6,9 +6,9 @@ import org.jboss.resteasy.reactive.server.core.BlockingOperationSupport; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; /** * This UserProvider stores and updates the credentials in the callback endpoint, and checks that it's non-blocking @@ -16,21 +16,27 @@ @ApplicationScoped public class WebAuthnNonBlockingTestUserProvider extends WebAuthnTestUserProvider { @Override - public Uni> findWebAuthnCredentialsByCredID(String credId) { + public Uni findByCredentialId(String credId) { assertBlockingNotAllowed(); - return super.findWebAuthnCredentialsByCredID(credId); + return super.findByCredentialId(credId); } @Override - public Uni> findWebAuthnCredentialsByUserName(String userId) { + public Uni> findByUsername(String userId) { assertBlockingNotAllowed(); - return super.findWebAuthnCredentialsByUserName(userId); + return super.findByUsername(userId); } @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { + public Uni update(String credentialId, long counter) { assertBlockingNotAllowed(); - return super.updateOrStoreWebAuthnCredentials(authenticator); + return super.update(credentialId, counter); + } + + @Override + public Uni store(WebAuthnCredentialRecord credentialRecord) { + assertBlockingNotAllowed(); + return super.store(credentialRecord); } private void assertBlockingNotAllowed() { diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnOriginsTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnOriginsTest.java new file mode 100644 index 0000000000000..faba447abe20a --- /dev/null +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnOriginsTest.java @@ -0,0 +1,46 @@ +package io.quarkus.security.webauthn.test; + +import org.hamcrest.Matchers; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +public class WebAuthnOriginsTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(WebAuthnManualTestUserProvider.class, WebAuthnTestUserProvider.class, TestUtil.class) + .addAsResource(new StringAsset("quarkus.webauthn.origins=http://foo,https://bar:42"), + "application.properties")); + + @Test + public void testLoginRpFromFirstOrigin() { + RestAssured + .given() + .contentType(ContentType.URLENC) + .queryParam("username", "foo") + .get("/q/webauthn/register-options-challenge") + .then() + .log().all() + .statusCode(200) + .contentType(ContentType.JSON) + .body("rp.id", Matchers.equalTo("foo")); + } + + @Test + public void testWellKnownConfigured() { + RestAssured.get("/.well-known/webauthn") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("origins.size()", Matchers.equalTo(2)) + .body("origins[0]", Matchers.equalTo("http://foo")) + .body("origins[1]", Matchers.equalTo("https://bar:42")); + } +} diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnTest.java index 1752a5ecac77a..03cf9cec1850a 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnTest.java @@ -5,8 +5,11 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; import io.restassured.RestAssured; +import io.restassured.filter.cookie.CookieFilter; +import io.restassured.http.ContentType; public class WebAuthnTest { @@ -15,8 +18,82 @@ public class WebAuthnTest { .withApplicationRoot((jar) -> jar .addClasses(WebAuthnManualTestUserProvider.class, WebAuthnTestUserProvider.class, TestUtil.class)); + @TestHTTPResource + public String url; + @Test public void testJavaScriptFile() { RestAssured.get("/q/webauthn/webauthn.js").then().statusCode(200).body(Matchers.startsWith("\"use strict\";")); } + + @Test + public void testLoginRpFromFirstOrigin() { + RestAssured + .given() + .contentType(ContentType.JSON) + .queryParam("username", "foo") + .get("/q/webauthn/register-options-challenge") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("rp.id", Matchers.equalTo("localhost")); + } + + @Test + public void testRegisterChallengeIsEqualAcrossCalls() { + CookieFilter cookieFilter = new CookieFilter(); + + String challenge = RestAssured + .given() + .filter(cookieFilter) + .contentType(ContentType.URLENC) + .queryParam("username", "foo") + .get("/q/webauthn/register-options-challenge") + .jsonPath().get("challenge"); + + RestAssured + .given() + .filter(cookieFilter) + .contentType(ContentType.URLENC) + .queryParam("username", "foo") + .get("/q/webauthn/register-options-challenge") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("challenge", Matchers.equalTo(challenge)); + } + + @Test + public void testLoginChallengeIsEqualAcrossCalls() { + CookieFilter cookieFilter = new CookieFilter(); + + String challenge = RestAssured + .given() + .filter(cookieFilter) + .contentType(ContentType.URLENC) + .get("/q/webauthn/login-options-challenge") + .jsonPath().get("challenge"); + + RestAssured + .given() + .filter(cookieFilter) + .contentType(ContentType.URLENC) + .get("/q/webauthn/login-options-challenge") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("challenge", Matchers.equalTo(challenge)); + } + + @Test + public void testWellKnownDefault() { + String origin = url; + if (origin.endsWith("/")) { + origin = origin.substring(0, origin.length() - 1); + } + RestAssured.get("/.well-known/webauthn").then().statusCode(200) + .contentType(ContentType.JSON) + .body("origins.size()", Matchers.equalTo(1)) + .body("origins[0]", Matchers.equalTo(origin)); + } } diff --git a/extensions/security-webauthn/runtime/pom.xml b/extensions/security-webauthn/runtime/pom.xml index cc609bd087d12..0aebc6cd0ba03 100644 --- a/extensions/security-webauthn/runtime/pom.xml +++ b/extensions/security-webauthn/runtime/pom.xml @@ -35,8 +35,12 @@ quarkus-vertx-http - io.vertx - vertx-auth-webauthn + com.webauthn4j + webauthn4j-core-async + + + com.webauthn4j + webauthn4j-metadata-async diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationMechanism.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationMechanism.java index 6ed0ef49744c3..ce07656549710 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationMechanism.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationMechanism.java @@ -69,7 +69,7 @@ static Uni getRedirect(final RoutingContext exchange, final Strin @Override public Set> getCredentialTypes() { - return new HashSet<>(Arrays.asList(WebAuthnAuthenticationRequest.class, TrustedAuthenticationRequest.class)); + return new HashSet<>(Arrays.asList(TrustedAuthenticationRequest.class)); } @Override diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationRequest.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationRequest.java deleted file mode 100644 index f24ac245ad060..0000000000000 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.quarkus.security.webauthn; - -import io.quarkus.security.identity.request.BaseAuthenticationRequest; -import io.vertx.ext.auth.webauthn.WebAuthnCredentials; - -public class WebAuthnAuthenticationRequest extends BaseAuthenticationRequest { - - private WebAuthnCredentials credentials; - - public WebAuthnAuthenticationRequest(WebAuthnCredentials credentials) { - this.credentials = credentials; - } - - public WebAuthnCredentials getCredentials() { - return credentials; - } - -} diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticatorStorage.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticatorStorage.java index ef680306535cb..a1031100ddfc1 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticatorStorage.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticatorStorage.java @@ -1,6 +1,5 @@ package io.quarkus.security.webauthn; -import java.util.Collections; import java.util.List; import java.util.function.Supplier; @@ -13,8 +12,6 @@ import io.smallrye.common.annotation.NonBlocking; import io.smallrye.common.annotation.RunOnVirtualThread; import io.smallrye.mutiny.Uni; -import io.vertx.core.Future; -import io.vertx.ext.auth.webauthn.Authenticator; import io.vertx.mutiny.core.Vertx; /** @@ -29,15 +26,20 @@ public class WebAuthnAuthenticatorStorage { @Inject Vertx vertx; - public Future> fetcher(Authenticator query) { - Uni> res; - if (query.getUserName() != null) - res = runPotentiallyBlocking(() -> userProvider.findWebAuthnCredentialsByUserName(query.getUserName())); - else if (query.getCredID() != null) - res = runPotentiallyBlocking(() -> userProvider.findWebAuthnCredentialsByCredID(query.getCredID())); - else - return Future.succeededFuture(Collections.emptyList()); - return Future.fromCompletionStage(res.subscribeAsCompletionStage()); + public Uni> findByUsername(String username) { + return runPotentiallyBlocking(() -> userProvider.findByUsername(username)); + } + + public Uni findByCredID(String credID) { + return runPotentiallyBlocking(() -> userProvider.findByCredentialId(credID)); + } + + public Uni create(WebAuthnCredentialRecord credentialRecord) { + return runPotentiallyBlocking(() -> userProvider.store(credentialRecord)); + } + + public Uni update(String credID, long counter) { + return runPotentiallyBlocking(() -> userProvider.update(credID, counter)); } @SuppressWarnings({ "rawtypes", "unchecked" }) @@ -80,10 +82,4 @@ private boolean isRunOnVirtualThread(Class klass) { // no information, assumed non-blocking return false; } - - public Future updater(Authenticator authenticator) { - return Future - .fromCompletionStage(runPotentiallyBlocking(() -> userProvider.updateOrStoreWebAuthnCredentials(authenticator)) - .subscribeAsCompletionStage()); - } } diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java index 0c7894568bcba..083a8b7cb5b26 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java @@ -1,187 +1,108 @@ package io.quarkus.security.webauthn; -import java.util.function.Consumer; - -import org.jboss.logging.Logger; +import java.util.function.Supplier; import io.quarkus.arc.Arc; import io.quarkus.arc.InjectableContext.ContextState; import io.quarkus.arc.ManagedContext; -import io.quarkus.security.identity.IdentityProviderManager; -import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; -import io.quarkus.vertx.http.runtime.security.PersistentLoginManager.RestoreResult; +import io.smallrye.mutiny.Uni; +import io.vertx.core.http.HttpHeaders; import io.vertx.core.json.JsonObject; -import io.vertx.ext.auth.webauthn.WebAuthnCredentials; -import io.vertx.ext.auth.webauthn.impl.attestation.AttestationException; import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.impl.Origin; /** * Endpoints for login/register/callback */ public class WebAuthnController { - private static final Logger log = Logger.getLogger(WebAuthnController.class); - - private String challengeUsernameCookie; - private String challengeCookie; - private WebAuthnSecurity security; - private String origin; - - private String domain; - - private IdentityProviderManager identityProviderManager; - - private WebAuthnAuthenticationMechanism authMech; - - public WebAuthnController(WebAuthnSecurity security, WebAuthnRunTimeConfig config, - IdentityProviderManager identityProviderManager, - WebAuthnAuthenticationMechanism authMech) { - origin = config.origin().orElse(null); - if (origin != null) { - Origin o = Origin.parse(origin); - domain = o.host(); - } + public WebAuthnController(WebAuthnSecurity security) { this.security = security; - this.identityProviderManager = identityProviderManager; - this.authMech = authMech; - this.challengeCookie = config.challengeCookieName(); - this.challengeUsernameCookie = config.challengeUsernameCookieName(); } - private static boolean containsRequiredString(JsonObject json, String key) { + /** + * Endpoint for getting a list of allowed origins + * + * @param ctx the current request + */ + public void wellKnown(RoutingContext ctx) { try { - if (json == null) { - return false; - } - if (!json.containsKey(key)) { - return false; - } - Object s = json.getValue(key); - return (s instanceof String) && !"".equals(s); - } catch (ClassCastException e) { - return false; + ctx.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(new JsonObject() + .put("origins", security.getAllowedOrigins(ctx)) + .encode()); + } catch (IllegalArgumentException e) { + ctx.fail(400, e); + } catch (RuntimeException e) { + ctx.fail(e); } } - private static boolean containsOptionalString(JsonObject json, String key) { + /** + * Endpoint for getting a register challenge and options + * + * @param ctx the current request + */ + public void registerOptionsChallenge(RoutingContext ctx) { try { - if (json == null) { - return true; - } - if (!json.containsKey(key)) { - return true; - } - Object s = json.getValue(key); - return (s instanceof String); - } catch (ClassCastException e) { - return false; + String username = ctx.queryParams().get("username"); + String displayName = ctx.queryParams().get("displayName"); + withContext(() -> security.getRegisterChallenge(username, displayName, ctx)) + .map(challenge -> security.toJsonString(challenge)) + .subscribe().with(challenge -> ok(ctx, challenge), ctx::fail); + + } catch (IllegalArgumentException e) { + ctx.fail(400, e); + } catch (RuntimeException e) { + ctx.fail(e); } } - private static boolean containsRequiredObject(JsonObject json, String key) { - try { - if (json == null) { - return false; - } - if (!json.containsKey(key)) { - return false; - } - JsonObject s = json.getJsonObject(key); - return s != null; - } catch (ClassCastException e) { - return false; - } + private Uni withContext(Supplier> uni) { + ManagedContext requestContext = Arc.container().requestContext(); + requestContext.activate(); + ContextState contextState = requestContext.getState(); + return uni.get().eventually(() -> requestContext.destroy(contextState)); } /** - * Endpoint for getting a register challenge + * Endpoint for getting a login challenge and options * * @param ctx the current request */ - public void register(RoutingContext ctx) { + public void loginOptionsChallenge(RoutingContext ctx) { try { - // might throw runtime exception if there's no json or is bad formed - final JsonObject webauthnRegister = ctx.getBodyAsJson(); - - // the register object should match a Webauthn user. - // A user has only a required field: name - // And optional fields: displayName and icon - if (webauthnRegister == null || !containsRequiredString(webauthnRegister, "name")) { - ctx.fail(400, new IllegalArgumentException("missing 'name' field from request json")); - } else { - // input basic validation is OK - - ManagedContext requestContext = Arc.container().requestContext(); - requestContext.activate(); - ContextState contextState = requestContext.getState(); - security.getWebAuthn().createCredentialsOptions(webauthnRegister, createCredentialsOptions -> { - requestContext.destroy(contextState); - if (createCredentialsOptions.failed()) { - ctx.fail(createCredentialsOptions.cause()); - return; - } + String username = ctx.queryParams().get("username"); + withContext(() -> security.getLoginChallenge(username, ctx)) + .map(challenge -> security.toJsonString(challenge)) + .subscribe().with(challenge -> ok(ctx, challenge), ctx::fail); - final JsonObject credentialsOptions = createCredentialsOptions.result(); - - // save challenge to the session - authMech.getLoginManager().save(credentialsOptions.getString("challenge"), ctx, challengeCookie, null, - ctx.request().isSSL()); - authMech.getLoginManager().save(webauthnRegister.getString("name"), ctx, challengeUsernameCookie, null, - ctx.request().isSSL()); - - ok(ctx, credentialsOptions); - }); - } } catch (IllegalArgumentException e) { ctx.fail(400, e); } catch (RuntimeException e) { ctx.fail(e); } + } /** - * Endpoint for getting a login challenge + * Endpoint for login. This will call {@link} * * @param ctx the current request */ public void login(RoutingContext ctx) { try { // might throw runtime exception if there's no json or is bad formed - final JsonObject webauthnLogin = ctx.getBodyAsJson(); - - if (webauthnLogin == null || !containsRequiredString(webauthnLogin, "name")) { - ctx.fail(400, new IllegalArgumentException("Request missing 'name' field")); - return; - } - - // input basic validation is OK - - final String username = webauthnLogin.getString("name"); - - ManagedContext requestContext = Arc.container().requestContext(); - requestContext.activate(); - ContextState contextState = requestContext.getState(); - // STEP 18 Generate assertion - security.getWebAuthn().getCredentialsOptions(username, generateServerGetAssertion -> { - requestContext.destroy(contextState); - if (generateServerGetAssertion.failed()) { - ctx.fail(generateServerGetAssertion.cause()); - return; - } - - final JsonObject getAssertion = generateServerGetAssertion.result(); - - authMech.getLoginManager().save(getAssertion.getString("challenge"), ctx, challengeCookie, null, - ctx.request().isSSL()); - authMech.getLoginManager().save(username, ctx, challengeUsernameCookie, null, - ctx.request().isSSL()); + final JsonObject webauthnResp = ctx.getBodyAsJson(); - ok(ctx, getAssertion); - }); + withContext(() -> security.login(webauthnResp, ctx)) + .onItem().call(record -> security.storage().update(record.getCredentialID(), record.getCounter())) + .subscribe().with(record -> { + security.rememberUser(record.getUsername(), ctx); + ok(ctx); + }, x -> ctx.fail(400, x)); } catch (IllegalArgumentException e) { ctx.fail(400, e); } catch (RuntimeException e) { @@ -191,76 +112,22 @@ public void login(RoutingContext ctx) { } /** - * Endpoint for getting authenticated + * Endpoint for registration * * @param ctx the current request */ - public void callback(RoutingContext ctx) { + public void register(RoutingContext ctx) { try { + final String username = ctx.queryParams().get("username"); // might throw runtime exception if there's no json or is bad formed final JsonObject webauthnResp = ctx.getBodyAsJson(); - // input validation - if (webauthnResp == null || - !containsRequiredString(webauthnResp, "id") || - !containsRequiredString(webauthnResp, "rawId") || - !containsRequiredObject(webauthnResp, "response") || - !containsOptionalString(webauthnResp.getJsonObject("response"), "userHandle") || - !containsRequiredString(webauthnResp, "type") || - !"public-key".equals(webauthnResp.getString("type"))) { - - ctx.fail(400, new IllegalArgumentException( - "Response missing one or more of id/rawId/response[.userHandle]/type fields, or type is not public-key")); - return; - } - RestoreResult challenge = authMech.getLoginManager().restore(ctx, challengeCookie); - RestoreResult username = authMech.getLoginManager().restore(ctx, challengeUsernameCookie); - if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty() - || username == null || username.getPrincipal() == null || username.getPrincipal().isEmpty()) { - ctx.fail(400, new IllegalArgumentException("Missing challenge or username")); - return; - } - - ManagedContext requestContext = Arc.container().requestContext(); - requestContext.activate(); - ContextState contextState = requestContext.getState(); - // input basic validation is OK - // authInfo - WebAuthnCredentials credentials = new WebAuthnCredentials() - .setOrigin(origin) - .setDomain(domain) - .setChallenge(challenge.getPrincipal()) - .setUsername(username.getPrincipal()) - .setWebauthn(webauthnResp); - identityProviderManager - .authenticate(HttpSecurityUtils - .setRoutingContextAttribute(new WebAuthnAuthenticationRequest(credentials), ctx)) - .subscribe().with(new Consumer() { - @Override - public void accept(SecurityIdentity identity) { - requestContext.destroy(contextState); - // invalidate the challenge - WebAuthnSecurity.removeCookie(ctx, challengeCookie); - WebAuthnSecurity.removeCookie(ctx, challengeUsernameCookie); - try { - authMech.getLoginManager().save(identity, ctx, null, ctx.request().isSSL()); - ok(ctx); - } catch (Throwable t) { - log.error("Unable to complete post authentication", t); - ctx.fail(t); - } - } - }, new Consumer() { - @Override - public void accept(Throwable throwable) { - requestContext.terminate(); - if (throwable instanceof AttestationException) { - ctx.fail(400, throwable); - } else { - ctx.fail(throwable); - } - } - }); + withContext(() -> security.register(username, webauthnResp, ctx)) + .onItem().call(record -> security.storage().create(record)) + .subscribe().with(record -> { + security.rememberUser(record.getUsername(), ctx); + ok(ctx); + }, x -> ctx.fail(400, x)); } catch (IllegalArgumentException e) { ctx.fail(400, e); } catch (RuntimeException e) { @@ -275,20 +142,22 @@ public void accept(Throwable throwable) { * @param ctx the current request */ public void logout(RoutingContext ctx) { - authMech.getLoginManager().clear(ctx); + security.logout(ctx); ctx.redirect("/"); } + private static void ok(RoutingContext ctx, String json) { + ctx.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(json); + } + private static void ok(RoutingContext ctx) { ctx.response() .setStatusCode(204) .end(); } - private static void ok(RoutingContext ctx, JsonObject result) { - ctx.json(result); - } - public void javascript(RoutingContext ctx) { ctx.response().sendFile("webauthn.js"); } diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnCredentialRecord.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnCredentialRecord.java new file mode 100644 index 0000000000000..1739ac63adfbb --- /dev/null +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnCredentialRecord.java @@ -0,0 +1,192 @@ +package io.quarkus.security.webauthn; + +import static io.vertx.ext.auth.impl.Codec.base64UrlDecode; + +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.EdECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.Set; +import java.util.UUID; + +import com.webauthn4j.credential.CredentialRecordImpl; +import com.webauthn4j.data.AuthenticatorTransport; +import com.webauthn4j.data.attestation.AttestationObject; +import com.webauthn4j.data.attestation.authenticator.AAGUID; +import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData; +import com.webauthn4j.data.attestation.authenticator.COSEKey; +import com.webauthn4j.data.attestation.authenticator.EC2COSEKey; +import com.webauthn4j.data.attestation.authenticator.EdDSACOSEKey; +import com.webauthn4j.data.attestation.authenticator.RSACOSEKey; +import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier; +import com.webauthn4j.data.client.CollectedClientData; +import com.webauthn4j.data.extension.client.AuthenticationExtensionsClientOutputs; +import com.webauthn4j.data.extension.client.RegistrationExtensionClientOutput; +import com.webauthn4j.util.Base64UrlUtil; + +/** + * This is the internal WebAuthn4J representation for a credential record, augmented with + * a user name. One user name can be shared among multiple credential records, but each + * credential record has a unique credential ID. + */ +public class WebAuthnCredentialRecord extends CredentialRecordImpl { + + private String username; + + /* + * This is used for registering + */ + public WebAuthnCredentialRecord(String username, + AttestationObject attestationObject, + CollectedClientData clientData, + AuthenticationExtensionsClientOutputs clientExtensions, + Set transports) { + super(attestationObject, clientData, clientExtensions, transports); + this.username = username; + } + + /* + * This is used for login + */ + private WebAuthnCredentialRecord(String username, + long counter, + AttestedCredentialData attestedCredentialData) { + super(null, null, null, null, counter, attestedCredentialData, null, null, null, null); + this.username = username; + } + + /** + * The increasing signature counter for usage of this credential record. See + * https://w3c.github.io/webauthn/#signature-counter + * + * @return The increasing signature counter. + */ + @Override + public long getCounter() { + // this method is just to get rid of deprecation warnings for users. + return super.getCounter(); + } + + /** + * The username for this credential record + * + * @return the username for this credential record + */ + public String getUsername() { + return username; + } + + /** + * The unique credential ID for this record. This is a convenience method returning a Base64Url-encoded + * version of getAttestedCredentialData().getCredentialId() + * + * @return The unique credential ID for this record + */ + public String getCredentialID() { + return Base64UrlUtil.encodeToString(getAttestedCredentialData().getCredentialId()); + } + + /** + * Returns the fields of this credential record that are necessary to persist for your users + * to be able to log back in using WebAuthn. + * + * @return the fields required to be persisted. + */ + public RequiredPersistedData getRequiredPersistedData() { + return new RequiredPersistedData(getUsername(), + getCredentialID(), + getAttestedCredentialData().getAaguid().getValue(), + getAttestedCredentialData().getCOSEKey().getPublicKey().getEncoded(), + getAttestedCredentialData().getCOSEKey().getAlgorithm().getValue(), + getCounter()); + } + + /** + * Reassembles a credential record from the given required persisted fields. + * + * @param persistedData the required fields to be able to log back in with WebAuthn. + * @return the internal representation of a WebAuthn credential record. + */ + public static WebAuthnCredentialRecord fromRequiredPersistedData(RequiredPersistedData persistedData) { + // important + long counter = persistedData.counter(); + X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(persistedData.publicKey); + COSEAlgorithmIdentifier coseAlgorithm = COSEAlgorithmIdentifier.create(persistedData.publicKeyAlgorithm); + COSEKey coseKey; + try { + switch (coseAlgorithm.getKeyType()) { + case EC2: + coseKey = EC2COSEKey.create((ECPublicKey) KeyFactory.getInstance("EC").generatePublic(x509EncodedKeySpec), + coseAlgorithm); + break; + case OKP: + coseKey = EdDSACOSEKey + .create((EdECPublicKey) KeyFactory.getInstance("EdDSA").generatePublic(x509EncodedKeySpec), + coseAlgorithm); + break; + case RSA: + coseKey = RSACOSEKey + .create((RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(x509EncodedKeySpec), + coseAlgorithm); + break; + default: + throw new IllegalArgumentException("Invalid cose algorithm: " + coseAlgorithm); + } + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new IllegalArgumentException("Invalid public key", e); + } + byte[] credentialId = base64UrlDecode(persistedData.credentialId()); + AAGUID aaguid = new AAGUID(persistedData.aaguid()); + AttestedCredentialData attestedCredentialData = new AttestedCredentialData(aaguid, credentialId, coseKey); + + return new WebAuthnCredentialRecord(persistedData.username(), counter, attestedCredentialData); + } + + /** + * Record holding all the required persistent fields for logging back someone over WebAuthn. + */ + public record RequiredPersistedData( + /** + * The user name. A single user name may be associated with multiple WebAuthn credentials. + */ + String username, + /** + * The credential ID. This must be unique. See https://w3c.github.io/webauthn/#credential-id + */ + String credentialId, + /** + * See https://w3c.github.io/webauthn/#aaguid + */ + UUID aaguid, + /** + * A X.509 encoding of the public key. See https://w3c.github.io/webauthn/#credential-public-key + */ + byte[] publicKey, + /** + * The COSE algorithm used for signing with the public key. See + * https://w3c.github.io/webauthn/#typedefdef-cosealgorithmidentifier + */ + long publicKeyAlgorithm, + /** + * The increasing signature counter for usage of this credential record. See + * https://w3c.github.io/webauthn/#signature-counter + */ + long counter) { + /** + * Returns a PEM-encoded representation of the public key. This is a utility method you can use as an alternate for + * storing the + * binary public key if you do not want to store a byte[] and prefer strings. + * + * @return a PEM-encoded representation of the public key + */ + public String getPublicKeyPEM() { + return "-----BEGIN PUBLIC KEY-----\n" + + Base64.getEncoder().encodeToString(publicKey) + + "\n-----END PUBLIC KEY-----\n"; + } + } +} diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnIdentityProvider.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnIdentityProvider.java deleted file mode 100644 index 8e1e62fffdd9a..0000000000000 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnIdentityProvider.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.quarkus.security.webauthn; - -import java.util.function.Consumer; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; - -import io.quarkus.security.identity.AuthenticationRequestContext; -import io.quarkus.security.identity.IdentityProvider; -import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.security.runtime.QuarkusPrincipal; -import io.quarkus.security.runtime.QuarkusSecurityIdentity; -import io.smallrye.mutiny.Uni; -import io.smallrye.mutiny.subscription.UniEmitter; -import io.vertx.core.AsyncResult; -import io.vertx.core.Handler; -import io.vertx.ext.auth.User; - -/** - * WebAuthn IdentityProvider - */ -@ApplicationScoped -public class WebAuthnIdentityProvider implements IdentityProvider { - - @Inject - WebAuthnSecurity security; - - @Override - public Class getRequestType() { - return WebAuthnAuthenticationRequest.class; - } - - @Override - public Uni authenticate(WebAuthnAuthenticationRequest request, AuthenticationRequestContext context) { - return Uni.createFrom().emitter(new Consumer>() { - @Override - public void accept(UniEmitter emitter) { - security.getWebAuthn().authenticate(request.getCredentials(), new Handler>() { - @Override - public void handle(AsyncResult event) { - if (event.failed()) { - emitter.fail(event.cause()); - } else { - QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); - // only the username matters, because when we auth we create a session cookie with it - // and we reply instantly so the roles are never used - builder.setPrincipal(new QuarkusPrincipal(request.getCredentials().getUsername())); - emitter.complete(builder.build()); - } - } - }); - } - }); - } - -} diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRecorder.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRecorder.java index b21affad39408..5fb56a234798e 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRecorder.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRecorder.java @@ -10,7 +10,6 @@ import io.quarkus.arc.runtime.BeanContainer; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; -import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.quarkus.vertx.http.runtime.security.PersistentLoginManager; import io.vertx.ext.web.Router; @@ -34,18 +33,24 @@ public WebAuthnRecorder(RuntimeValue httpConfiguration, Runti public void setupRoutes(BeanContainer beanContainer, RuntimeValue routerValue, String prefix) { WebAuthnSecurity security = beanContainer.beanInstance(WebAuthnSecurity.class); - WebAuthnAuthenticationMechanism authMech = beanContainer.beanInstance(WebAuthnAuthenticationMechanism.class); - IdentityProviderManager identityProviderManager = beanContainer.beanInstance(IdentityProviderManager.class); - WebAuthnController controller = new WebAuthnController(security, config.getValue(), identityProviderManager, authMech); + WebAuthnController controller = new WebAuthnController(security); Router router = routerValue.getValue(); BodyHandler bodyHandler = BodyHandler.create(); // FIXME: paths configurable // prefix is the non-application root path, ends with a slash: defaults to /q/ - router.post(prefix + "webauthn/login").handler(bodyHandler).handler(controller::login); - router.post(prefix + "webauthn/register").handler(bodyHandler).handler(controller::register); - router.post(prefix + "webauthn/callback").handler(bodyHandler).handler(controller::callback); + router.get(prefix + "webauthn/login-options-challenge").handler(bodyHandler) + .handler(controller::loginOptionsChallenge); + router.get(prefix + "webauthn/register-options-challenge").handler(bodyHandler) + .handler(controller::registerOptionsChallenge); + if (config.getValue().enableLoginEndpoint().orElse(false)) { + router.post(prefix + "webauthn/login").handler(bodyHandler).handler(controller::login); + } + if (config.getValue().enableRegistrationEndpoint().orElse(false)) { + router.post(prefix + "webauthn/register").handler(bodyHandler).handler(controller::register); + } router.get(prefix + "webauthn/webauthn.js").handler(controller::javascript); router.get(prefix + "webauthn/logout").handler(controller::logout); + router.get("/.well-known/webauthn").handler(controller::wellKnown); } public Supplier setupWebAuthnAuthenticationMechanism() { diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java index ab7ef3ea30dcd..1daac0b41b7c0 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java @@ -5,17 +5,16 @@ import java.util.Optional; import java.util.OptionalInt; +import com.webauthn4j.data.AttestationConveyancePreference; +import com.webauthn4j.data.ResidentKeyRequirement; +import com.webauthn4j.data.UserVerificationRequirement; + import io.quarkus.runtime.annotations.ConfigDocDefault; import io.quarkus.runtime.annotations.ConfigGroup; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; import io.smallrye.config.ConfigMapping; import io.smallrye.config.WithDefault; -import io.vertx.ext.auth.webauthn.Attestation; -import io.vertx.ext.auth.webauthn.AuthenticatorAttachment; -import io.vertx.ext.auth.webauthn.AuthenticatorTransport; -import io.vertx.ext.auth.webauthn.PublicKeyCredential; -import io.vertx.ext.auth.webauthn.UserVerification; /** * Webauthn runtime configuration object. @@ -24,6 +23,172 @@ @ConfigRoot(phase = ConfigPhase.RUN_TIME) public interface WebAuthnRunTimeConfig { + /** + * COSEAlgorithm + * https://www.iana.org/assignments/cose/cose.xhtml#algorithms + */ + public enum COSEAlgorithm { + ES256(-7), + ES384(-35), + ES512(-36), + PS256(-37), + PS384(-38), + PS512(-39), + ES256K(-47), + RS256(-257), + RS384(-258), + RS512(-259), + RS1(-65535), + EdDSA(-8); + + private final int coseId; + + COSEAlgorithm(int coseId) { + this.coseId = coseId; + } + + public static COSEAlgorithm valueOf(int coseId) { + switch (coseId) { + case -7: + return ES256; + case -35: + return ES384; + case -36: + return ES512; + case -37: + return PS256; + case -38: + return PS384; + case -39: + return PS512; + case -47: + return ES256K; + case -257: + return RS256; + case -258: + return RS384; + case -259: + return RS512; + case -65535: + return RS1; + case -8: + return EdDSA; + default: + throw new IllegalArgumentException("Unknown cose-id: " + coseId); + } + } + + public int coseId() { + return coseId; + } + } + + /** + * AttestationConveyancePreference + * https://www.w3.org/TR/webauthn/#attestation-convey + */ + public enum Attestation { + NONE, + INDIRECT, + DIRECT, + ENTERPRISE; + + AttestationConveyancePreference toWebAuthn4J() { + switch (this) { + case DIRECT: + return AttestationConveyancePreference.DIRECT; + case ENTERPRISE: + return AttestationConveyancePreference.ENTERPRISE; + case INDIRECT: + return AttestationConveyancePreference.INDIRECT; + case NONE: + return AttestationConveyancePreference.NONE; + default: + throw new IllegalStateException("Illegal enum value: " + this); + } + } + } + + /** + * UserVerificationRequirement + * https://www.w3.org/TR/webauthn/#enumdef-userverificationrequirement + */ + public enum UserVerification { + REQUIRED, + PREFERRED, + DISCOURAGED; + + UserVerificationRequirement toWebAuthn4J() { + switch (this) { + case DISCOURAGED: + return UserVerificationRequirement.DISCOURAGED; + case PREFERRED: + return UserVerificationRequirement.PREFERRED; + case REQUIRED: + return UserVerificationRequirement.REQUIRED; + default: + throw new IllegalStateException("Illegal enum value: " + this); + } + } + } + + /** + * AuthenticatorAttachment + * https://www.w3.org/TR/webauthn/#enumdef-authenticatorattachment + */ + public enum AuthenticatorAttachment { + PLATFORM, + CROSS_PLATFORM; + + com.webauthn4j.data.AuthenticatorAttachment toWebAuthn4J() { + switch (this) { + case CROSS_PLATFORM: + return com.webauthn4j.data.AuthenticatorAttachment.CROSS_PLATFORM; + case PLATFORM: + return com.webauthn4j.data.AuthenticatorAttachment.PLATFORM; + default: + throw new IllegalStateException("Illegal enum value: " + this); + } + } + } + + /** + * AuthenticatorTransport + * https://www.w3.org/TR/webauthn/#enumdef-authenticatortransport + */ + public enum AuthenticatorTransport { + USB, + NFC, + BLE, + HYBRID, + INTERNAL; + } + + /** + * ResidentKey + * https://www.w3.org/TR/webauthn-2/#dictdef-authenticatorselectioncriteria + * + * This enum is used to specify the desired behaviour for resident keys with the authenticator. + */ + public enum ResidentKey { + DISCOURAGED, + PREFERRED, + REQUIRED; + + ResidentKeyRequirement toWebAuthn4J() { + switch (this) { + case DISCOURAGED: + return ResidentKeyRequirement.DISCOURAGED; + case PREFERRED: + return ResidentKeyRequirement.PREFERRED; + case REQUIRED: + return ResidentKeyRequirement.REQUIRED; + default: + throw new IllegalStateException("Illegal enum value: " + this); + } + } + } + /** * SameSite attribute values for the session cookie. */ @@ -34,7 +199,7 @@ enum CookieSameSite { } /** - * The origin of the application. The origin is basically protocol, host and port. + * The origins of the application. The origin is basically protocol, host and port. * * If you are calling WebAuthn API while your application is located at {@code https://example.com/login}, * then origin will be {@code https://example.com}. @@ -44,8 +209,14 @@ enum CookieSameSite { * * Please note that WebAuthn API will not work on pages loaded over HTTP, unless it is localhost, * which is considered secure context. + * + * If unspecified, this defaults to whatever URI this application is deployed on. + * + * This allows more than one value if you want to allow multiple origins. See + * https://w3c.github.io/webauthn/#sctn-related-origins */ - Optional origin(); + @ConfigDocDefault("The URI this application is deployed on") + Optional> origins(); /** * Authenticator Transports allowed by the application. Authenticators can interact with the user web browser @@ -86,12 +257,19 @@ enum CookieSameSite { */ Optional authenticatorAttachment(); + /** + * Load the FIDO metadata for verification. See https://fidoalliance.org/metadata/. Only useful for attestations + * different from {@code Attestation.NONE}. + */ + @ConfigDocDefault("false") + Optional loadMetadata(); + /** * Resident key required. A resident (private) key, is a key that cannot leave your authenticator device, this * means that you cannot reuse the authenticator to log into a second computer. */ - @ConfigDocDefault("false") - Optional requireResidentKey(); + @ConfigDocDefault("REQUIRED") + Optional residentKey(); /** * User Verification requirements. Webauthn applications may choose {@code REQUIRED} verification to assert that @@ -104,15 +282,21 @@ enum CookieSameSite { *

  • {@code DISCOURAGED} - User should avoid interact with the browser
  • * */ - @ConfigDocDefault("DISCOURAGED") + @ConfigDocDefault("REQUIRED") Optional userVerification(); + /** + * User presence requirements. + */ + @ConfigDocDefault("true") + Optional userPresenceRequired(); + /** * Non-negative User Verification timeout. Authentication must occur within the timeout, this will prevent the user * browser from being blocked with a pop-up required user verification, and the whole ceremony must be completed * within the timeout period. After the timeout, any previously issued challenge is automatically invalidated. */ - @ConfigDocDefault("60s") + @ConfigDocDefault("5m") Optional timeout(); /** @@ -144,9 +328,11 @@ enum CookieSameSite { * * Note that the use of stronger algorithms, e.g.: {@code EdDSA} may require Java 15 or a cryptographic {@code JCE} * provider that implements the algorithms. + * + * See https://www.w3.org/TR/webauthn-1/#dictdef-publickeycredentialparameters */ @ConfigDocDefault("ES256,RS256") - Optional> pubKeyCredParams(); + Optional> publicKeyCredentialParameters(); /** * Length of the challenges exchanged between the application and the browser. @@ -180,8 +366,10 @@ enum CookieSameSite { @ConfigGroup interface RelyingPartyConfig { /** - * The id (or domain name of your server) + * The id (or domain name of your server, as obtained from the first entry of origins or looking + * at where this request is being served from) */ + @ConfigDocDefault("The host name of the first allowed origin, or the host where this application is deployed") Optional id(); /** @@ -237,12 +425,6 @@ interface RelyingPartyConfig { @WithDefault("_quarkus_webauthn_challenge") public String challengeCookieName(); - /** - * The cookie that is used to store the username data during login/registration - */ - @WithDefault("_quarkus_webauthn_username") - public String challengeUsernameCookieName(); - /** * SameSite attribute for the session cookie. */ @@ -261,4 +443,20 @@ interface RelyingPartyConfig { * The default value is empty, which means the cookie will be kept until the browser is closed. */ Optional cookieMaxAge(); + + /** + * Set to true if you want to enable the default registration endpoint at /q/webauthn/register, in + * which case + * you should also implement the WebAuthnUserProvider.store method. + */ + @WithDefault("false") + Optional enableRegistrationEndpoint(); + + /** + * Set to true if you want to enable the default login endpoint at /q/webauthn/login, in which + * case + * you should also implement the WebAuthnUserProvider.update method. + */ + @WithDefault("false") + Optional enableLoginEndpoint(); } diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java index d803512ea5a34..7e2df1ea59900 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java @@ -1,23 +1,80 @@ package io.quarkus.security.webauthn; +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; import java.security.Principal; +import java.security.cert.CertificateException; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import com.webauthn4j.async.WebAuthnAsyncManager; +import com.webauthn4j.async.anchor.KeyStoreTrustAnchorAsyncRepository; +import com.webauthn4j.async.anchor.TrustAnchorAsyncRepository; +import com.webauthn4j.async.metadata.FidoMDS3MetadataBLOBAsyncProvider; +import com.webauthn4j.async.metadata.HttpAsyncClient; +import com.webauthn4j.async.metadata.anchor.MetadataBLOBBasedTrustAnchorAsyncRepository; +import com.webauthn4j.async.verifier.attestation.statement.androidkey.AndroidKeyAttestationStatementAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.statement.androidsafetynet.AndroidSafetyNetAttestationStatementAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.statement.apple.AppleAnonymousAttestationStatementAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.statement.packed.PackedAttestationStatementAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.statement.tpm.TPMAttestationStatementAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.statement.u2f.FIDOU2FAttestationStatementAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.trustworthiness.certpath.DefaultCertPathTrustworthinessAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.trustworthiness.self.DefaultSelfAttestationTrustworthinessAsyncVerifier; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.AuthenticationParameters; +import com.webauthn4j.data.AuthenticatorSelectionCriteria; +import com.webauthn4j.data.PublicKeyCredentialCreationOptions; +import com.webauthn4j.data.PublicKeyCredentialDescriptor; +import com.webauthn4j.data.PublicKeyCredentialParameters; +import com.webauthn4j.data.PublicKeyCredentialRequestOptions; +import com.webauthn4j.data.PublicKeyCredentialRpEntity; +import com.webauthn4j.data.PublicKeyCredentialType; +import com.webauthn4j.data.PublicKeyCredentialUserEntity; +import com.webauthn4j.data.RegistrationParameters; +import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.data.extension.client.AuthenticationExtensionsClientInputs; +import com.webauthn4j.server.ServerProperty; +import com.webauthn4j.util.Base64UrlUtil; + import io.quarkus.security.runtime.QuarkusPrincipal; import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.Attestation; +import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.AuthenticatorAttachment; +import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.COSEAlgorithm; +import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.ResidentKey; +import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.UserVerification; +import io.quarkus.security.webauthn.impl.VertxHttpAsyncClient; +import io.quarkus.tls.TlsConfiguration; +import io.quarkus.tls.TlsConfigurationRegistry; import io.quarkus.vertx.http.runtime.security.PersistentLoginManager.RestoreResult; import io.smallrye.mutiny.Uni; import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; import io.vertx.core.http.Cookie; -import io.vertx.ext.auth.webauthn.Authenticator; -import io.vertx.ext.auth.webauthn.RelyingParty; -import io.vertx.ext.auth.webauthn.WebAuthn; -import io.vertx.ext.auth.webauthn.WebAuthnCredentials; -import io.vertx.ext.auth.webauthn.WebAuthnOptions; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.impl.CertificateHelper; +import io.vertx.ext.auth.impl.CertificateHelper.CertInfo; +import io.vertx.ext.auth.impl.jose.JWS; +import io.vertx.ext.auth.prng.VertxContextPRNG; import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.impl.Origin; /** * Utility class that allows users to manually login or register users using WebAuthn @@ -25,138 +82,530 @@ @ApplicationScoped public class WebAuthnSecurity { - private WebAuthn webAuthn; - private String origin; - private String domain; + /* + * Android Keystore Root is not published anywhere. + * This certificate was extracted from one of the attestations + * The last certificate in x5c must match this certificate + * This needs to be checked to ensure that malicious party won't generate fake attestations + */ + private static final String ANDROID_KEYSTORE_ROOT = "MIICizCCAjKgAwIBAgIJAKIFntEOQ1tXMAoGCCqGSM49BAMCMIGYMQswCQYDVQQG" + + "EwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNTW91bnRhaW4gVmll" + + "dzEVMBMGA1UECgwMR29vZ2xlLCBJbmMuMRAwDgYDVQQLDAdBbmRyb2lkMTMwMQYD" + + "VQQDDCpBbmRyb2lkIEtleXN0b3JlIFNvZnR3YXJlIEF0dGVzdGF0aW9uIFJvb3Qw" + + "HhcNMTYwMTExMDA0MzUwWhcNMzYwMTA2MDA0MzUwWjCBmDELMAkGA1UEBhMCVVMx" + + "EzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFTAT" + + "BgNVBAoMDEdvb2dsZSwgSW5jLjEQMA4GA1UECwwHQW5kcm9pZDEzMDEGA1UEAwwq" + + "QW5kcm9pZCBLZXlzdG9yZSBTb2Z0d2FyZSBBdHRlc3RhdGlvbiBSb290MFkwEwYH" + + "KoZIzj0CAQYIKoZIzj0DAQcDQgAE7l1ex+HA220Dpn7mthvsTWpdamguD/9/SQ59" + + "dx9EIm29sa/6FsvHrcV30lacqrewLVQBXT5DKyqO107sSHVBpKNjMGEwHQYDVR0O" + + "BBYEFMit6XdMRcOjzw0WEOR5QzohWjDPMB8GA1UdIwQYMBaAFMit6XdMRcOjzw0W" + + "EOR5QzohWjDPMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgKEMAoGCCqG" + + "SM49BAMCA0cAMEQCIDUho++LNEYenNVg8x1YiSBq3KNlQfYNns6KGYxmSGB7AiBN" + + "C/NR2TB8fVvaNTQdqEcbY6WFZTytTySn502vQX3xvw=="; + + // https://aboutssl.org/globalsign-root-certificates-licensing-and-use/ + // Name gsr1 + // Thumbprint: b1:bc:96:8b:d4:f4:9d:62:2a:a8:9a:81:f2:15:01:52:a4:1d:82:9c + // Valid Until 28 January 2028 + private static final String GSR1 = "MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG\n" + + "A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv\n" + + "b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw\n" + + "MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i\n" + + "YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT\n" + + "aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ\n" + + "jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp\n" + + "xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp\n" + + "1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG\n" + + "snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ\n" + + "U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8\n" + + "9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E\n" + + "BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B\n" + + "AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz\n" + + "yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE\n" + + "38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP\n" + + "AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad\n" + + "DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME\n" + + "HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A=="; + + /** + * Apple WebAuthn Root CA PEM + *

    + * Downloaded from https://www.apple.com/certificateauthority/Apple_WebAuthn_Root_CA.pem + *

    + * Valid until 03/14/2045 @ 5:00 PM PST + */ + private static final String APPLE_WEBAUTHN_ROOT_CA = "MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w" + + "HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ" + + "bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx" + + "NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG" + + "A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49" + + "AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k" + + "xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/" + + "pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk" + + "2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA" + + "MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3" + + "jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B" + + "1bWeT0vT"; + + /** + * Default FIDO2 MDS3 ROOT Certificate + *

    + * Downloaded from https://valid.r3.roots.globalsign.com/ + *

    + * Valid until 18 March 2029 + */ + private static final String FIDO_MDS3_ROOT_CERTIFICATE = "MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4G" + + + "A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNp" + + "Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4" + + "MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEG" + + "A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI" + + "hvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8" + + "RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsT" + + "gHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmm" + + "KPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zd" + + "QQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZ" + + "XriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAw" + + "DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+o" + + "LkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZU" + + "RUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMp" + + "jjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK" + + "6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQX" + + "mcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecs" + + "Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH" + + "WD9f"; + + @Inject + TlsConfigurationRegistry certificates; @Inject WebAuthnAuthenticationMechanism authMech; + + @Inject + WebAuthnAuthenticatorStorage storage; + + private ObjectConverter objectConverter = new ObjectConverter(); + private WebAuthnAsyncManager webAuthn; + private VertxContextPRNG random; + private String challengeCookie; - private String challengeUsernameCookie; + + private List origins; + private String rpId; + private String rpName; + + private UserVerification userVerification; + private Boolean userPresenceRequired; + private List pubKeyCredParams; + private ResidentKey residentKey; + + private Duration timeout; + private int challengeLength; + private AuthenticatorAttachment authenticatorAttachment; + + private Attestation attestation; public WebAuthnSecurity(WebAuthnRunTimeConfig config, Vertx vertx, WebAuthnAuthenticatorStorage database) { - // create the webauthn security object - WebAuthnOptions options = new WebAuthnOptions(); - RelyingParty relyingParty = new RelyingParty(); - if (config.relyingParty().id().isPresent()) { - relyingParty.setId(config.relyingParty().id().get()); - } - // this is required - relyingParty.setName(config.relyingParty().name()); - options.setRelyingParty(relyingParty); - if (config.attestation().isPresent()) { - options.setAttestation(config.attestation().get()); - } - if (config.authenticatorAttachment().isPresent()) { - options.setAuthenticatorAttachment(config.authenticatorAttachment().get()); - } - if (config.challengeLength().isPresent()) { - options.setChallengeLength(config.challengeLength().getAsInt()); + // apply config defaults + this.rpId = config.relyingParty().id().orElse(null); + this.rpName = config.relyingParty().name(); + this.origins = config.origins().orElse(Collections.emptyList()); + this.challengeCookie = config.challengeCookieName(); + this.challengeLength = config.challengeLength().orElse(64); + this.userPresenceRequired = config.userPresenceRequired().orElse(true); + this.timeout = config.timeout().orElse(Duration.ofMinutes(5)); + if (config.publicKeyCredentialParameters().isPresent()) { + this.pubKeyCredParams = new ArrayList<>(config.publicKeyCredentialParameters().get().size()); + for (COSEAlgorithm publicKeyCredential : config.publicKeyCredentialParameters().get()) { + this.pubKeyCredParams.add(new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, + COSEAlgorithmIdentifier.create(publicKeyCredential.coseId()))); + } + } else { + this.pubKeyCredParams = new ArrayList<>(2); + this.pubKeyCredParams + .add(new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256)); + this.pubKeyCredParams + .add(new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.RS256)); } - if (config.pubKeyCredParams().isPresent()) { - options.setPubKeyCredParams(config.pubKeyCredParams().get()); + this.authenticatorAttachment = config.authenticatorAttachment().orElse(null); + this.userVerification = config.userVerification().orElse(UserVerification.REQUIRED); + this.residentKey = config.residentKey().orElse(ResidentKey.REQUIRED); + this.attestation = config.attestation().orElse(Attestation.NONE); + // create the webauthn4j manager + this.webAuthn = makeWebAuthn(vertx, config); + this.random = VertxContextPRNG.current(vertx); + } + + private String randomBase64URLBuffer() { + final byte[] buff = new byte[challengeLength]; + random.nextBytes(buff); + return Base64UrlUtil.encodeToString(buff); + } + + private WebAuthnAsyncManager makeWebAuthn(Vertx vertx, WebAuthnRunTimeConfig config) { + if (config.attestation().isPresent() + && config.attestation().get() != WebAuthnRunTimeConfig.Attestation.NONE) { + TrustAnchorAsyncRepository something; + // FIXME: make config name configurable? + Optional webauthnTlsConfiguration = certificates.get("webauthn"); + KeyStore trustStore; + if (webauthnTlsConfiguration.isPresent()) { + trustStore = webauthnTlsConfiguration.get().getTrustStore(); + } else { + try { + trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + addCert(trustStore, ANDROID_KEYSTORE_ROOT); + addCert(trustStore, APPLE_WEBAUTHN_ROOT_CA); + addCert(trustStore, FIDO_MDS3_ROOT_CERTIFICATE); + addCert(trustStore, GSR1); + } catch (CertificateException | KeyStoreException | NoSuchAlgorithmException | IOException e) { + throw new RuntimeException("Failed to configure default WebAuthn certificates", e); + } + } + Set trustAnchors = new HashSet<>(); + try { + Enumeration aliases = trustStore.aliases(); + while (aliases.hasMoreElements()) { + trustAnchors.add(new TrustAnchor((X509Certificate) trustStore.getCertificate(aliases.nextElement()), null)); + } + } catch (KeyStoreException e) { + throw new RuntimeException("Failed to configure WebAuthn trust store", e); + } + // FIXME CLRs are not supported yet + something = new KeyStoreTrustAnchorAsyncRepository(trustStore); + if (config.loadMetadata().orElse(false)) { + HttpAsyncClient httpClient = new VertxHttpAsyncClient(vertx); + FidoMDS3MetadataBLOBAsyncProvider blobAsyncProvider = new FidoMDS3MetadataBLOBAsyncProvider(objectConverter, + FidoMDS3MetadataBLOBAsyncProvider.DEFAULT_BLOB_ENDPOINT, httpClient, trustAnchors); + something = new MetadataBLOBBasedTrustAnchorAsyncRepository(blobAsyncProvider); + } + + return new WebAuthnAsyncManager( + Arrays.asList( + new FIDOU2FAttestationStatementAsyncVerifier(), + new PackedAttestationStatementAsyncVerifier(), + new TPMAttestationStatementAsyncVerifier(), + new AndroidKeyAttestationStatementAsyncVerifier(), + new AndroidSafetyNetAttestationStatementAsyncVerifier(), + new AppleAnonymousAttestationStatementAsyncVerifier()), + new DefaultCertPathTrustworthinessAsyncVerifier(something), + new DefaultSelfAttestationTrustworthinessAsyncVerifier(), + objectConverter); + + } else { + return WebAuthnAsyncManager.createNonStrictWebAuthnAsyncManager(objectConverter); } - if (config.requireResidentKey().isPresent()) { - options.setRequireResidentKey(config.requireResidentKey().get()); + } + + private void addCert(KeyStore keyStore, String pemCertificate) throws CertificateException, KeyStoreException { + X509Certificate cert = JWS.parseX5c(pemCertificate); + CertInfo info = CertificateHelper.getCertInfo(cert); + keyStore.setCertificateEntry(info.subject("CN"), cert); + } + + private static byte[] uUIDBytes(UUID uuid) { + Buffer buffer = Buffer.buffer(16); + buffer.setLong(0, uuid.getMostSignificantBits()); + buffer.setLong(8, uuid.getLeastSignificantBits()); + return buffer.getBytes(); + } + + /** + * Obtains a registration challenge for the given required username and displayName. This will also + * create and save a challenge in a session cookie. + * + * @param username the username for the registration + * @param displayName the displayName for the registration + * @param ctx the Vert.x context + * @return the registration challenge. + */ + @SuppressWarnings("unused") + public Uni getRegisterChallenge(String username, String displayName, + RoutingContext ctx) { + if (username == null || username.isEmpty()) { + return Uni.createFrom().failure(new IllegalArgumentException("Username is required")); } - if (config.timeout().isPresent()) { - options.setTimeoutInMilliseconds(config.timeout().get().toMillis()); + // default displayName to username, but it's required really + if (displayName == null || displayName.isEmpty()) { + displayName = username; } - if (config.transports().isPresent()) { - options.setTransports(config.transports().get()); + String finalDisplayName = displayName; + String challenge = getOrCreateChallenge(ctx); + Origin origin = Origin.create(!this.origins.isEmpty() ? this.origins.get(0) : ctx.request().absoluteURI()); + String rpId = this.rpId != null ? this.rpId : origin.getHost(); + + return storage.findByUsername(username) + .map(credentials -> { + List excluded; + // See https://github.com/quarkusio/quarkus/issues/44292 for why this is currently disabled + if (false) { + excluded = new ArrayList<>(credentials.size()); + for (WebAuthnCredentialRecord credential : credentials) { + excluded.add(new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PUBLIC_KEY, + credential.getAttestedCredentialData().getCredentialId(), + credential.getTransports())); + } + } else { + excluded = Collections.emptyList(); + } + PublicKeyCredentialCreationOptions publicKeyCredentialCreationOptions = new PublicKeyCredentialCreationOptions( + new PublicKeyCredentialRpEntity( + rpId, + rpName), + new PublicKeyCredentialUserEntity( + uUIDBytes(UUID.randomUUID()), + username, + finalDisplayName), + new DefaultChallenge(challenge), + pubKeyCredParams, + timeout.getSeconds() * 1000, + excluded, + new AuthenticatorSelectionCriteria( + authenticatorAttachment != null ? authenticatorAttachment.toWebAuthn4J() : null, + residentKey == ResidentKey.REQUIRED, + residentKey.toWebAuthn4J(), + userVerification.toWebAuthn4J()), + attestation.toWebAuthn4J(), + new AuthenticationExtensionsClientInputs<>()); + + // save challenge to the session + authMech.getLoginManager().save(challenge, ctx, challengeCookie, null, + ctx.request().isSSL()); + + return publicKeyCredentialCreationOptions; + }); + + } + + /** + * Obtains a login challenge for the given optional username. This will also + * create and save a challenge in a session cookie. + * + * @param username the optional username for the login + * @param ctx the Vert.x context + * @return the login challenge. + */ + @SuppressWarnings("unused") + public Uni getLoginChallenge(String username, RoutingContext ctx) { + // Username is not required with passkeys + if (username == null) { + username = ""; } - if (config.userVerification().isPresent()) { - options.setUserVerification(config.userVerification().get()); + String finalUsername = username; + String challenge = getOrCreateChallenge(ctx); + Origin origin = Origin.create(!this.origins.isEmpty() ? this.origins.get(0) : ctx.request().absoluteURI()); + String rpId = this.rpId != null ? this.rpId : origin.getHost(); + + // do not attempt to look users up if there's no user name + Uni> credentialsUni; + if (username.isEmpty()) { + credentialsUni = Uni.createFrom().item(Collections.emptyList()); + } else { + credentialsUni = storage.findByUsername(username); } - webAuthn = WebAuthn.create(vertx, options) - // where to load/update authenticators data - .authenticatorFetcher(database::fetcher) - .authenticatorUpdater(database::updater); - origin = config.origin().orElse(null); - if (origin != null) { - Origin o = Origin.parse(origin); - domain = o.host(); + return credentialsUni + .map(credentials -> { + List allowedCredentials; + // See https://github.com/quarkusio/quarkus/issues/44292 for why this is currently disabled + if (false) { + + if (credentials.isEmpty()) { + throw new RuntimeException("No credentials found for " + finalUsername); + } + allowedCredentials = new ArrayList<>(credentials.size()); + for (WebAuthnCredentialRecord credential : credentials) { + allowedCredentials.add(new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PUBLIC_KEY, + credential.getAttestedCredentialData().getCredentialId(), + credential.getTransports())); + } + } else { + allowedCredentials = Collections.emptyList(); + } + PublicKeyCredentialRequestOptions publicKeyCredentialRequestOptions = new PublicKeyCredentialRequestOptions( + new DefaultChallenge(challenge), + timeout.getSeconds() * 1000, + rpId, + allowedCredentials, + userVerification.toWebAuthn4J(), + null); + + // save challenge to the session + authMech.getLoginManager().save(challenge, ctx, challengeCookie, null, + ctx.request().isSSL()); + + return publicKeyCredentialRequestOptions; + }); + } + + private String getOrCreateChallenge(RoutingContext ctx) { + RestoreResult challengeRestoreResult = authMech.getLoginManager().restore(ctx, challengeCookie); + String challenge; + if (challengeRestoreResult == null || challengeRestoreResult.getPrincipal() == null + || challengeRestoreResult.getPrincipal().isEmpty()) { + challenge = randomBase64URLBuffer(); + } else { + challenge = challengeRestoreResult.getPrincipal(); } - this.challengeCookie = config.challengeCookieName(); - this.challengeUsernameCookie = config.challengeUsernameCookieName(); + return challenge; } /** - * Registers a new WebAuthn credentials + * Registers a new WebAuthn credentials. This will check it, clear the challenge cookie and return it in case of + * success, but not invoke {@link WebAuthnUserProvider#store(WebAuthnCredentialRecord)}, you have to do + * it manually in case of success. This will also not set a login cookie, you have to do it manually using + * {@link #rememberUser(String, RoutingContext)} + * or using any other way. * + * @param the username to register credentials for * @param response the Webauthn registration info * @param ctx the current request * @return the newly created credentials */ - public Uni register(WebAuthnRegisterResponse response, RoutingContext ctx) { - // validation of the response is done before + public Uni register(String username, WebAuthnRegisterResponse response, RoutingContext ctx) { + return register(username, response.toJsonObject(), ctx); + } + + /** + * Registers a new WebAuthn credentials. This will check it, clear the challenge cookie and return it in case of + * success, but not invoke {@link WebAuthnUserProvider#store(WebAuthnCredentialRecord)}, you have to do + * it manually in case of success. This will also not set a login cookie, you have to do it manually using + * {@link #rememberUser(String, RoutingContext)} + * or using any other way. + * + * @param the username to register credentials for + * @param response the Webauthn registration info + * @param ctx the current request + * @return the newly created credentials + */ + public Uni register(String username, JsonObject response, RoutingContext ctx) { RestoreResult challenge = authMech.getLoginManager().restore(ctx, challengeCookie); - RestoreResult username = authMech.getLoginManager().restore(ctx, challengeUsernameCookie); - if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty() - || username == null || username.getPrincipal() == null || username.getPrincipal().isEmpty()) { - return Uni.createFrom().failure(new RuntimeException("Missing challenge or username")); - } - - return Uni.createFrom().emitter(emitter -> { - webAuthn.authenticate( - // authInfo - new WebAuthnCredentials() - .setOrigin(origin) - .setDomain(domain) - .setChallenge(challenge.getPrincipal()) - .setUsername(username.getPrincipal()) - .setWebauthn(response.toJsonObject()), - authenticate -> { - removeCookie(ctx, challengeCookie); - removeCookie(ctx, challengeUsernameCookie); - if (authenticate.succeeded()) { - // this is registration, so the caller will want to store the created Authenticator, - // let's recreate it - emitter.complete(new Authenticator(authenticate.result().principal())); - } else { - emitter.fail(authenticate.cause()); - } - }); - }); + if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty()) { + return Uni.createFrom().failure(new RuntimeException("Missing challenge")); + } + if (username == null || username.isEmpty()) { + return Uni.createFrom().failure(new RuntimeException("Missing username")); + } + + // input validation + if (response == null || + !containsRequiredString(response, "id") || + !containsRequiredString(response, "rawId") || + !containsRequiredObject(response, "response") || + !containsOptionalString(response.getJsonObject("response"), "userHandle") || + !containsRequiredString(response, "type") || + !"public-key".equals(response.getString("type"))) { + + return Uni.createFrom().failure(new IllegalArgumentException( + "Response missing one or more of id/rawId/response[.userHandle]/type fields, or type is not public-key")); + } + String registrationResponseJSON = response.encode(); + + ServerProperty serverProperty = makeServerProperty(challenge, ctx); + RegistrationParameters registrationParameters = new RegistrationParameters(serverProperty, pubKeyCredParams, + userVerification == UserVerification.REQUIRED, userPresenceRequired); + + return Uni.createFrom() + .completionStage(webAuthn.verifyRegistrationResponseJSON(registrationResponseJSON, registrationParameters)) + .eventually(() -> { + removeCookie(ctx, challengeCookie); + }).map(registrationData -> new WebAuthnCredentialRecord( + username, + registrationData.getAttestationObject(), + registrationData.getCollectedClientData(), + registrationData.getClientExtensions(), + registrationData.getTransports())); + } + + private ServerProperty makeServerProperty(RestoreResult challenge, RoutingContext ctx) { + Set origins = new HashSet<>(); + Origin firstOrigin = null; + if (this.origins.isEmpty()) { + firstOrigin = Origin.create(ctx.request().absoluteURI()); + origins.add(firstOrigin); + } else { + for (String origin : this.origins) { + Origin newOrigin = Origin.create(origin); + if (firstOrigin == null) { + firstOrigin = newOrigin; + origins.add(newOrigin); + } + } + } + String rpId = this.rpId != null ? this.rpId : firstOrigin.getHost(); + DefaultChallenge challengeObject = new DefaultChallenge(challenge.getPrincipal()); + return new ServerProperty(origins, rpId, challengeObject, /* this is deprecated in Level 3, so ignore it */ null); + } + + /** + * Logs an existing WebAuthn user in. This will check it, clear the challenge cookie and return the updated credentials in + * case of + * success, but not invoke {@link WebAuthnUserProvider#update(String, long)}, you have to do + * it manually in case of success. This will also not set a login cookie, you have to do it manually using + * {@link #rememberUser(String, RoutingContext)} + * or using any other way. + * + * @param response the Webauthn login info + * @param ctx the current request + * @return the updated credentials + */ + public Uni login(WebAuthnLoginResponse response, RoutingContext ctx) { + return login(response.toJsonObject(), ctx); } /** - * Logs an existing WebAuthn user in + * Logs an existing WebAuthn user in. This will check it, clear the challenge cookie and return the updated credentials in + * case of + * success, but not invoke {@link WebAuthnUserProvider#update(String, long)}, you have to do + * it manually in case of success. This will also not set a login cookie, you have to do it manually using + * {@link #rememberUser(String, RoutingContext)} + * or using any other way. * * @param response the Webauthn login info * @param ctx the current request * @return the updated credentials */ - public Uni login(WebAuthnLoginResponse response, RoutingContext ctx) { - // validation of the response is done before + public Uni login(JsonObject response, RoutingContext ctx) { RestoreResult challenge = authMech.getLoginManager().restore(ctx, challengeCookie); - RestoreResult username = authMech.getLoginManager().restore(ctx, challengeUsernameCookie); if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty() - || username == null || username.getPrincipal() == null || username.getPrincipal().isEmpty()) { - return Uni.createFrom().failure(new RuntimeException("Missing challenge or username")); - } - - return Uni.createFrom().emitter(emitter -> { - webAuthn.authenticate( - // authInfo - new WebAuthnCredentials() - .setOrigin(origin) - .setDomain(domain) - .setChallenge(challenge.getPrincipal()) - .setUsername(username.getPrincipal()) - .setWebauthn(response.toJsonObject()), - authenticate -> { - removeCookie(ctx, challengeCookie); - removeCookie(ctx, challengeUsernameCookie); - if (authenticate.succeeded()) { - // this is login, so the user will want to bump the counter - // FIXME: do we need the auth here? likely the user will know it and will just ++ on the DB-stored counter, no? - emitter.complete(new Authenticator(authenticate.result().principal())); - } else { - emitter.fail(authenticate.cause()); - } - }); - }); + // although login can be empty, we should still have a cookie for it + ) { + return Uni.createFrom().failure(new RuntimeException("Missing challenge")); + } + + // input validation + if (response == null || + !containsRequiredString(response, "id") || + !containsRequiredString(response, "rawId") || + !containsRequiredObject(response, "response") || + !containsOptionalString(response.getJsonObject("response"), "userHandle") || + !containsRequiredString(response, "type") || + !"public-key".equals(response.getString("type"))) { + + return Uni.createFrom().failure(new IllegalArgumentException( + "Response missing one or more of id/rawId/response[.userHandle]/type fields, or type is not public-key")); + } + + String authenticationResponseJSON = response.encode(); + // validated + String rawId = response.getString("rawId"); + + ServerProperty serverProperty = makeServerProperty(challenge, ctx); + + return storage.findByCredID(rawId) + .chain(credentialRecord -> { + List allowCredentials = List.of(Base64UrlUtil.decode(rawId)); + AuthenticationParameters authenticationParameters = new AuthenticationParameters(serverProperty, + credentialRecord, allowCredentials, + userVerification == UserVerification.REQUIRED, userPresenceRequired); + + return Uni.createFrom() + .completionStage(webAuthn.verifyAuthenticationResponseJSON(authenticationResponseJSON, + authenticationParameters)) + .eventually(() -> { + removeCookie(ctx, challengeCookie); + }).map(authenticationData -> credentialRecord); + }); } static void removeCookie(RoutingContext ctx, String name) { @@ -170,11 +619,11 @@ static void removeCookie(RoutingContext ctx, String name) { } /** - * Returns the underlying Vert.x WebAuthn authenticator + * Returns the underlying WebAuthn4J authenticator * - * @return the underlying Vert.x WebAuthn authenticator + * @return the underlying WebAuthn4J authenticator */ - public WebAuthn getWebAuthn() { + public WebAuthnAsyncManager getWebAuthn4J() { return webAuthn; } @@ -198,4 +647,72 @@ public void rememberUser(String userID, RoutingContext ctx) { public void logout(RoutingContext ctx) { authMech.getLoginManager().clear(ctx); } + + static boolean containsRequiredString(JsonObject json, String key) { + try { + if (json == null) { + return false; + } + if (!json.containsKey(key)) { + return false; + } + Object s = json.getValue(key); + return (s instanceof String) && !"".equals(s); + } catch (ClassCastException e) { + return false; + } + } + + private static boolean containsOptionalString(JsonObject json, String key) { + try { + if (json == null) { + return true; + } + if (!json.containsKey(key)) { + return true; + } + Object s = json.getValue(key); + return (s instanceof String); + } catch (ClassCastException e) { + return false; + } + } + + private static boolean containsRequiredObject(JsonObject json, String key) { + try { + if (json == null) { + return false; + } + if (!json.containsKey(key)) { + return false; + } + JsonObject s = json.getJsonObject(key); + return s != null; + } catch (ClassCastException e) { + return false; + } + } + + public String toJsonString(PublicKeyCredentialCreationOptions challenge) { + return objectConverter.getJsonConverter().writeValueAsString(challenge); + } + + public String toJsonString(PublicKeyCredentialRequestOptions challenge) { + return objectConverter.getJsonConverter().writeValueAsString(challenge); + } + + /** + * Returns the list of allowed origins, or defaults to the current request's origin if unconfigured. + */ + public List getAllowedOrigins(RoutingContext ctx) { + if (this.origins.isEmpty()) { + return List.of(Origin.create(ctx.request().absoluteURI()).toString()); + } else { + return this.origins; + } + } + + WebAuthnAuthenticatorStorage storage() { + return storage; + } } diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnUserProvider.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnUserProvider.java index b74c45363eb50..d90f43e694767 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnUserProvider.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnUserProvider.java @@ -5,7 +5,6 @@ import java.util.Set; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; /** * Implement this interface in order to tell Quarkus WebAuthn how to look up @@ -14,37 +13,73 @@ */ public interface WebAuthnUserProvider { /** - * Look up a WebAuthn credential by username + * Look up a WebAuthn credential by username. This should return an empty list Uni if the user name is not found. * - * @param userName the username - * @return a list of credentials for this username + * @param username the username + * @return a list of credentials for this username, or an empty list if there are no credentials or if the user name is + * not found. */ - public Uni> findWebAuthnCredentialsByUserName(String userName); + public Uni> findByUsername(String username); /** - * Look up a WebAuthn credential by credential ID + * Look up a WebAuthn credential by credential ID, this should return an exception Uni rather than return a null-item Uni + * in case the credential is not found. * * @param credentialId the credential ID - * @returna list of credentials for this credential ID. + * @return a credentials for this credential ID. + * @throws an exception Uni if the credential ID is unknown */ - public Uni> findWebAuthnCredentialsByCredID(String credentialId); + public Uni findByCredentialId(String credentialId); /** - * If this credential's combination of user and credential ID does not exist, - * then store the new credential. If it already exists, then only update its counter + * Update an existing WebAuthn credential's counter. This is only used by the default login endpoint, which + * is disabled by default and can be enabled via the quarkus.webauthn.enable-login-endpoint. + * You don't have to implement this method + * if you handle logins manually via {@link WebAuthnSecurity#login(WebAuthnLoginResponse, io.vertx.ext.web.RoutingContext)}. * - * @param authenticator the new credential if it does not exist, or the credential to update + * The default behaviour is to not do anything. + * + * @param credentialId the credential ID * @return a uni completion object */ - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator); + public default Uni update(String credentialId, long counter) { + return Uni.createFrom().voidItem(); + } + + /** + * Store a new WebAuthn credential. This is only used by the default registration endpoint, which + * is disabled by default and can be enabled via the quarkus.webauthn.enable-registration-endpoint. + * You don't have to implement this method if you handle registration manually via + * {@link WebAuthnSecurity#register(WebAuthnRegisterResponse, io.vertx.ext.web.RoutingContext)} + * + * Make sure that you never allow creating + * new credentials for a `username` that already exists. Otherwise you risk allowing third-parties to impersonate existing + * users by letting them add their own credentials to existing accounts. If you want to allow existing users to register + * more than one WebAuthn credential, you must make sure that the user is currently logged + * in under the same username to which you want to add new credentials. In every other case, make sure to + * return a failed + * {@link Uni} from this method. + * + * The default behaviour is to not do anything. + * + * @param credentialRecord the new credentials to store + * @return a uni completion object + * @throws Exception a failed {@link Uni} if the credentialId already exists, or the username + * already + * has a credential and you disallow having more, or if trying to add credentials to other users than the current + * user. + */ + public default Uni store(WebAuthnCredentialRecord credentialRecord) { + return Uni.createFrom().voidItem(); + } /** * Returns the set of roles for the given username * - * @param userName the username + * @param username the username * @return the set of roles (defaults to an empty set) */ - public default Set getRoles(String userName) { + public default Set getRoles(String username) { return Collections.emptySet(); } } diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/impl/VertxHttpAsyncClient.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/impl/VertxHttpAsyncClient.java new file mode 100644 index 0000000000000..755b9810b216b --- /dev/null +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/impl/VertxHttpAsyncClient.java @@ -0,0 +1,41 @@ +package io.quarkus.security.webauthn.impl; + +import java.io.ByteArrayInputStream; +import java.util.concurrent.CompletionStage; + +import com.webauthn4j.async.metadata.HttpAsyncClient; +import com.webauthn4j.metadata.HttpClient.Response; +import com.webauthn4j.metadata.exception.MDSException; + +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.auth.impl.http.SimpleHttpClient; + +public class VertxHttpAsyncClient implements HttpAsyncClient { + + private static final byte[] NO_BYTES = new byte[0]; + private SimpleHttpClient httpClient; + + public VertxHttpAsyncClient(Vertx vertx) { + this.httpClient = new SimpleHttpClient(vertx, "vertx-auth", new HttpClientOptions()); + } + + @Override + public CompletionStage fetch(String uri) throws MDSException { + return httpClient + .fetch(HttpMethod.GET, uri, null, null) + .map(res -> { + Buffer body = res.body(); + byte[] bytes; + if (body != null) { + bytes = body.getBytes(); + } else { + bytes = NO_BYTES; + } + return new Response(res.statusCode(), new ByteArrayInputStream(bytes)); + }).toCompletionStage(); + } + +} diff --git a/extensions/security-webauthn/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/security-webauthn/runtime/src/main/resources/META-INF/quarkus-extension.yaml index b13ed8dc74128..8e56c89d705c1 100644 --- a/extensions/security-webauthn/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/security-webauthn/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -8,6 +8,6 @@ metadata: guide: "https://quarkus.io/guides/security-webauthn" categories: - "security" - status: "preview" + status: "experimental" config: - "quarkus.webauthn." diff --git a/extensions/security-webauthn/runtime/src/main/resources/webauthn.js b/extensions/security-webauthn/runtime/src/main/resources/webauthn.js index e38b2982fc23e..c9a88f47be721 100644 --- a/extensions/security-webauthn/runtime/src/main/resources/webauthn.js +++ b/extensions/security-webauthn/runtime/src/main/resources/webauthn.js @@ -94,30 +94,38 @@ * Licensed under the Apache 2 license. */ - function WebAuthn(options) { - this.registerPath = options.registerPath; - this.loginPath = options.loginPath; - this.callbackPath = options.callbackPath; - // validation - if (!this.callbackPath) { - throw new Error('Callback path is missing!'); - } + function WebAuthn(options = {}) { + this.registerOptionsChallengePath = options.registerOptionsChallengePath || "/q/webauthn/register-options-challenge"; + this.loginOptionsChallengePath = options.loginOptionsChallengePath || "/q/webauthn/login-options-challenge"; + this.registerPath = options.registerPath || "/q/webauthn/register"; + this.loginPath = options.loginPath || "/q/webauthn/login"; + this.csrf = options.csrf; } WebAuthn.constructor = WebAuthn; - WebAuthn.prototype.registerOnly = function (user) { + WebAuthn.prototype.fetchWithCsrf = function (path, options) { + const self = this; + if(self.csrf) { + if(!options.headers) { + options.headers = {}; + } + options.headers[self.csrf.header] = self.csrf.value; + } + return fetch(path, options); + } + + WebAuthn.prototype.registerClientSteps = function (user) { const self = this; - if (!self.registerPath) { - return Promise.reject('Register path missing form the initial configuration!'); + if (!self.registerOptionsChallengePath) { + return Promise.reject('Register challenge path missing form the initial configuration!'); } - return fetch(self.registerPath, { - method: 'POST', + return self.fetchWithCsrf(self.registerOptionsChallengePath + "?" + new URLSearchParams({username: user.username, displayName: user.displayName}).toString(), { + method: 'GET', headers: { 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify(user || {}) + 'Content-Type': 'application/x-www-form-urlencoded' + } }) .then(res => { if (res.status === 200) { @@ -152,9 +160,15 @@ WebAuthn.prototype.register = function (user) { const self = this; - return self.registerOnly(user) + if (!self.registerPath) { + throw new Error('Register path is missing!'); + } + if (!user || !user.username) { + return Promise.reject('User name (user.username) required'); + } + return self.registerClientSteps(user) .then(body => { - return fetch(self.callbackPath, { + return self.fetchWithCsrf(self.registerPath + "?" + new URLSearchParams({username: user.username}).toString(), { method: 'POST', headers: { 'Accept': 'application/json', @@ -173,9 +187,12 @@ WebAuthn.prototype.login = function (user) { const self = this; - return self.loginOnly(user) + if (!self.loginPath) { + throw new Error('Login path is missing!'); + } + return self.loginClientSteps(user) .then(body => { - return fetch(self.callbackPath, { + return self.fetchWithCsrf(self.loginPath, { method: 'POST', headers: { 'Accept': 'application/json', @@ -192,18 +209,21 @@ }); }; - WebAuthn.prototype.loginOnly = function (user) { + WebAuthn.prototype.loginClientSteps = function (user) { const self = this; - if (!self.loginPath) { - return Promise.reject('Login path missing from the initial configuration!'); + if (!self.loginOptionsChallengePath) { + return Promise.reject('Login challenge path missing from the initial configuration!'); + } + let path = self.loginOptionsChallengePath + if (user != null && user.username != null) { + path = path + "?" + new URLSearchParams({username: user.username}).toString() } - return fetch(self.loginPath, { - method: 'POST', + return self.fetchWithCsrf(path, { + method: 'GET', headers: { 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify(user) + 'Content-Type': 'application/x-www-form-urlencoded' + } }) .then(res => { if (res.status === 200) { diff --git a/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/SecurityIdentityAugmentorsPermissionCheckerTest.java b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/SecurityIdentityAugmentorsPermissionCheckerTest.java new file mode 100644 index 0000000000000..4fdba98068504 --- /dev/null +++ b/extensions/security/deployment/src/test/java/io/quarkus/security/test/permissionsallowed/checker/SecurityIdentityAugmentorsPermissionCheckerTest.java @@ -0,0 +1,127 @@ +package io.quarkus.security.test.permissionsallowed.checker; + +import static io.quarkus.security.test.utils.IdentityMock.ADMIN; +import static io.quarkus.security.test.utils.IdentityMock.USER; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertFailureFor; +import static io.quarkus.security.test.utils.SecurityTestUtils.assertSuccess; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.logging.Log; +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.SecurityIdentityAugmentor; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.security.test.utils.AuthData; +import io.quarkus.security.test.utils.IdentityMock; +import io.quarkus.security.test.utils.SecurityTestUtils; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; + +public class SecurityIdentityAugmentorsPermissionCheckerTest { + + private static final AuthData USER_WITH_AUGMENTORS = new AuthData(USER, true); + private static final AuthData ADMIN_WITH_AUGMENTORS = new AuthData(ADMIN, true); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar.addClasses(IdentityMock.class, AuthData.class, SecurityTestUtils.class)); + + @Inject + SecuredBean bean; + + /** + * Tests that {@link SecurityIdentity} passed to the {@link PermissionChecker} methods is augmented by all the + * augmentors (because that's the last operation we do on the identity, then it's de facto final). + */ + @Test + public void testPermissionCheckerUsesAugmentedIdentity() { + assertSuccess(bean::securedMethod, "secured", ADMIN_WITH_AUGMENTORS); + assertFailureFor(bean::securedMethod, ForbiddenException.class, USER_WITH_AUGMENTORS); + } + + @ApplicationScoped + public static class SecuredBean { + + @PermissionsAllowed("canCallSecuredMethod") + String securedMethod() { + return "secured"; + } + + @PermissionChecker("canCallSecuredMethod") + boolean canCallSecuredMethod(SecurityIdentity identity) { + if (!identity.hasRole("lowest-priority-augmentor")) { + Log.error("Role granted by the augmentor with the smallest priority is missing"); + return false; + } + if (!identity.hasRole("default-priority-augmentor")) { + Log.error("Role granted by the augmentor with a default priority is missing"); + return false; + } + if (!identity.hasRole("highest-priority-augmentor")) { + Log.error("Role granted by the augmentor with the highest priority is missing"); + return false; + } + return "admin".equals(identity.getPrincipal().getName()); + } + } + + @ApplicationScoped + public static class AugmentorWithLowestPriority implements SecurityIdentityAugmentor { + + @Override + public int priority() { + return Integer.MIN_VALUE; + } + + @Override + public Uni augment(SecurityIdentity securityIdentity, + AuthenticationRequestContext authenticationRequestContext) { + return Uni.createFrom().item( + QuarkusSecurityIdentity + .builder(securityIdentity) + .addRole("lowest-priority-augmentor") + .build()); + } + } + + @ApplicationScoped + public static class AugmentorWithDefaultPriority implements SecurityIdentityAugmentor { + + @Override + public Uni augment(SecurityIdentity securityIdentity, + AuthenticationRequestContext authenticationRequestContext) { + return Uni.createFrom().item( + QuarkusSecurityIdentity + .builder(securityIdentity) + .addRole("default-priority-augmentor") + .build()); + } + } + + @ApplicationScoped + public static class AugmentorWithHighestPriority implements SecurityIdentityAugmentor { + + @Override + public int priority() { + return Integer.MAX_VALUE; + } + + @Override + public Uni augment(SecurityIdentity securityIdentity, + AuthenticationRequestContext authenticationRequestContext) { + return Uni.createFrom().item( + QuarkusSecurityIdentity + .builder(securityIdentity) + .addRole("highest-priority-augmentor") + .build()); + } + } +} diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusIdentityProviderManagerImpl.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusIdentityProviderManagerImpl.java index 6749d79d9c230..783e09e325fd5 100644 --- a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusIdentityProviderManagerImpl.java +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusIdentityProviderManagerImpl.java @@ -182,6 +182,7 @@ public static class Builder { private final Map, List>> providers = new HashMap<>(); private final List augmentors = new ArrayList<>(); + private QuarkusPermissionSecurityIdentityAugmentor quarkusPermissionAugmentor = null; private BlockingSecurityExecutor blockingExecutor; private boolean built = false; @@ -206,7 +207,11 @@ public Builder addProvider(IdentityProvider pro * @return this builder */ public Builder addSecurityIdentityAugmentor(SecurityIdentityAugmentor augmentor) { - augmentors.add(augmentor); + if (augmentor instanceof QuarkusPermissionSecurityIdentityAugmentor quarkusPermissionAugmentor) { + this.quarkusPermissionAugmentor = quarkusPermissionAugmentor; + } else { + augmentors.add(augmentor); + } return this; } @@ -254,6 +259,10 @@ public int compare(SecurityIdentityAugmentor o1, SecurityIdentityAugmentor o2) { return Integer.compare(o2.priority(), o1.priority()); } }); + if (quarkusPermissionAugmentor != null) { + // @PermissionChecker methods must always run with the final SecurityIdentity + augmentors.add(quarkusPermissionAugmentor); + } return new QuarkusIdentityProviderManagerImpl(this); } } diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermissionSecurityIdentityAugmentor.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermissionSecurityIdentityAugmentor.java index 7ca713dc4c4bd..6557b84a3b1fc 100644 --- a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermissionSecurityIdentityAugmentor.java +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermissionSecurityIdentityAugmentor.java @@ -61,4 +61,10 @@ public Uni apply(Permission requiredpermission) { }) .build()); } + + @Override + public int priority() { + // we do not rely on this value and always add this augmentor as the last one manually + return Integer.MAX_VALUE; + } } diff --git a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/IdentityMock.java b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/IdentityMock.java index 78240d4fd25f8..5e2ee21aaa923 100644 --- a/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/IdentityMock.java +++ b/extensions/security/test-utils/src/main/java/io/quarkus/security/test/utils/IdentityMock.java @@ -6,21 +6,19 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; -import java.util.function.Supplier; import jakarta.annotation.Priority; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Alternative; -import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; -import io.quarkus.arc.Arc; import io.quarkus.security.credential.Credential; import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.security.identity.SecurityIdentityAugmentor; +import io.quarkus.security.identity.request.BaseAuthenticationRequest; import io.quarkus.security.runtime.SecurityIdentityAssociation; -import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; import io.smallrye.mutiny.Uni; /** @@ -107,16 +105,17 @@ public Uni checkPermission(Permission permission) { @ApplicationScoped @Priority(1) public static class IdentityAssociationMock extends SecurityIdentityAssociation { + @Inject IdentityMock identity; @Inject - Instance augmentors; + IdentityProviderManager identityProviderManager; @Override public Uni getDeferredIdentity() { if (applyAugmentors) { - return augmentIdentity(identity); + return identityProviderManager.authenticate(new IdentityMockAuthenticationRequest()); } return Uni.createFrom().item(identity); } @@ -124,25 +123,32 @@ public Uni getDeferredIdentity() { @Override public SecurityIdentity getIdentity() { if (applyAugmentors) { - return augmentIdentity(identity).await().indefinitely(); + return getDeferredIdentity().await().indefinitely(); } return identity; } - private Uni augmentIdentity(SecurityIdentity identity) { - var authReqContexts = new TestAuthenticationRequestContext(); - Uni result = Uni.createFrom().item(identity); - for (SecurityIdentityAugmentor augmentor : augmentors) { - result = result.flatMap(si -> augmentor.augment(si, authReqContexts, Map.of())); - } - return result; + } + + public static final class IdentityMockAuthenticationRequest extends BaseAuthenticationRequest { + + } + + @ApplicationScoped + public static final class IdentityMockProvider implements IdentityProvider { + + @Inject + IdentityMock identity; + + @Override + public Class getRequestType() { + return IdentityMockAuthenticationRequest.class; } - private static final class TestAuthenticationRequestContext implements AuthenticationRequestContext { - @Override - public Uni runBlocking(Supplier function) { - return Arc.container().instance(BlockingSecurityExecutor.class).get().executeBlocking(function); - } + @Override + public Uni authenticate(IdentityMockAuthenticationRequest identityMockAuthenticationRequest, + AuthenticationRequestContext authenticationRequestContext) { + return Uni.createFrom().item(identity); } } } diff --git a/extensions/smallrye-graphql/deployment/src/main/java/io/quarkus/smallrye/graphql/deployment/SmallRyeGraphQLProcessor.java b/extensions/smallrye-graphql/deployment/src/main/java/io/quarkus/smallrye/graphql/deployment/SmallRyeGraphQLProcessor.java index f0ea44583d256..f63ee7467a648 100644 --- a/extensions/smallrye-graphql/deployment/src/main/java/io/quarkus/smallrye/graphql/deployment/SmallRyeGraphQLProcessor.java +++ b/extensions/smallrye-graphql/deployment/src/main/java/io/quarkus/smallrye/graphql/deployment/SmallRyeGraphQLProcessor.java @@ -641,13 +641,6 @@ private Set getAllReferenceClasses(Reference reference) { return classes; } - @BuildStep - void excludeNullFieldsInResponses(SmallRyeGraphQLConfig graphQLConfig, - BuildProducer systemProperties) { - systemProperties.produce(new SystemPropertyBuildItem(ConfigKey.EXCLUDE_NULL_FIELDS_IN_RESPONSES, - String.valueOf(graphQLConfig.excludeNullFieldsInResponses.orElse(false)))); - } - @BuildStep void printDataFetcherExceptionInDevMode(SmallRyeGraphQLConfig graphQLConfig, LaunchModeBuildItem launchMode, diff --git a/extensions/smallrye-graphql/deployment/src/test/java/io/quarkus/smallrye/graphql/deployment/ExcludeNullFieldsInResponseTest.java b/extensions/smallrye-graphql/deployment/src/test/java/io/quarkus/smallrye/graphql/deployment/ExcludeNullFieldsInResponseTest.java new file mode 100644 index 0000000000000..bcf839cf299b2 --- /dev/null +++ b/extensions/smallrye-graphql/deployment/src/test/java/io/quarkus/smallrye/graphql/deployment/ExcludeNullFieldsInResponseTest.java @@ -0,0 +1,86 @@ +package io.quarkus.smallrye.graphql.deployment; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.containsString; +import static org.jboss.resteasy.reactive.RestResponse.StatusCode.OK; + +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Query; +import org.jboss.shrinkwrap.api.asset.EmptyAsset; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class ExcludeNullFieldsInResponseTest extends AbstractGraphQLTest { + + @RegisterExtension + static QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(TestApi.class, Book.class, Author.class) + .addAsManifestResource(EmptyAsset.INSTANCE, "beans.xml") + .addAsResource( + new StringAsset( + "quarkus.smallrye-graphql.exclude-null-fields-in-responses=true"), + "application.properties")); + + @Test + void testExcludeNullFieldsInResponse() { + final String request = getPayload(""" + { + books { + name + pages + author { + firstName + lastName + } + } + }"""); + given() + .when() + .accept(MEDIATYPE_JSON) + .contentType(MEDIATYPE_JSON) + .body(request) + .post("/graphql") + .then() + .assertThat() + .statusCode(OK) + .and() + .body(containsString("{\"data\":{" + + "\"books\":[{" + + "\"name\":\"The Hobbit\"," + + // missing null field + "\"author\":{" + + "\"firstName\":\"J.R.R.\"" + + // missing null field + "}" + + "},{" + + "\"name\":\"The Lord of the Rings\"," + + "\"pages\":1178," + + "\"author\":{" + + "\"firstName\":\"J.R.R.\"," + + "\"lastName\":\"Tolkien\"" + + "}" + + "}]" + + "}}")); + } + + @GraphQLApi + public static class TestApi { + @Query + public Book[] getBooks() { + return new Book[] { + new Book("The Hobbit", null, new Author("J.R.R.", null)), + new Book("The Lord of the Rings", 1178, new Author("J.R.R.", "Tolkien")) + }; + } + } + + public record Book(String name, Integer pages, Author author) { + } + + public record Author(String firstName, String lastName) { + } +} diff --git a/extensions/smallrye-graphql/deployment/src/test/java/io/quarkus/smallrye/graphql/deployment/HideCheckedExceptionMessageTest.java b/extensions/smallrye-graphql/deployment/src/test/java/io/quarkus/smallrye/graphql/deployment/HideCheckedExceptionMessageTest.java new file mode 100644 index 0000000000000..7c9c13fd86af7 --- /dev/null +++ b/extensions/smallrye-graphql/deployment/src/test/java/io/quarkus/smallrye/graphql/deployment/HideCheckedExceptionMessageTest.java @@ -0,0 +1,95 @@ +package io.quarkus.smallrye.graphql.deployment; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.not; +import static org.jboss.resteasy.reactive.RestResponse.StatusCode.OK; + +import java.io.IOException; +import java.sql.SQLException; + +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Query; +import org.jboss.shrinkwrap.api.asset.EmptyAsset; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class HideCheckedExceptionMessageTest extends AbstractGraphQLTest { + + private static final String IOEXCEPTION_MESSAGE = "Something went wrong"; + private static final String INTERRUPTED_EXCEPTION_MESSAGE = "Something else went wrong"; + private static final String SQL_EXCEPTION_MESSAGE = "Something went really wrong, but should expect a message"; + + @RegisterExtension + static QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(TestApi.class) + .addAsManifestResource(EmptyAsset.INSTANCE, "beans.xml") + .addAsResource( + new StringAsset( + "quarkus.smallrye-graphql.hide-checked-exception-message=" + + "java.io.IOException," + + "java.lang.InterruptedException"), + "application.properties")); + + @Test + void testExcludeNullFieldsInResponse() { + given() + .when() + .accept(MEDIATYPE_JSON) + .contentType(MEDIATYPE_JSON) + .body(getPayload("{ something }")) + .post("/graphql") + .then() + .assertThat() + .statusCode(OK) + .and() + .body(not(containsString(IOEXCEPTION_MESSAGE))); + + given() + .when() + .accept(MEDIATYPE_JSON) + .contentType(MEDIATYPE_JSON) + .body(getPayload("{ somethingElse }")) + .post("/graphql") + .then() + .assertThat() + .statusCode(OK) + .and() + .body(not(containsString(INTERRUPTED_EXCEPTION_MESSAGE))); + + given() + .when() + .accept(MEDIATYPE_JSON) + .contentType(MEDIATYPE_JSON) + .body(getPayload("{ somethingElseElse }")) + .post("/graphql") + .then() + .assertThat() + .statusCode(OK) + .and() + .body(containsString(SQL_EXCEPTION_MESSAGE)); + } + + @GraphQLApi + public static class TestApi { + @Query + public String getSomething() throws IOException { + throw new IOException(IOEXCEPTION_MESSAGE); + } + + @Query + public String getSomethingElse() throws InterruptedException { + throw new InterruptedException(INTERRUPTED_EXCEPTION_MESSAGE); + } + + @Query + public String getSomethingElseElse() throws SQLException { + throw new SQLException(SQL_EXCEPTION_MESSAGE); + } + } + +} diff --git a/extensions/smallrye-graphql/deployment/src/test/java/io/quarkus/smallrye/graphql/deployment/ShowRuntimeExceptionMessageTest.java b/extensions/smallrye-graphql/deployment/src/test/java/io/quarkus/smallrye/graphql/deployment/ShowRuntimeExceptionMessageTest.java new file mode 100644 index 0000000000000..8256ef413c017 --- /dev/null +++ b/extensions/smallrye-graphql/deployment/src/test/java/io/quarkus/smallrye/graphql/deployment/ShowRuntimeExceptionMessageTest.java @@ -0,0 +1,71 @@ +package io.quarkus.smallrye.graphql.deployment; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.containsString; +import static org.jboss.resteasy.reactive.RestResponse.StatusCode.OK; + +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Query; +import org.jboss.shrinkwrap.api.asset.EmptyAsset; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class ShowRuntimeExceptionMessageTest extends AbstractGraphQLTest { + + private static final String ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE = "Something went wrong"; + private static final String ILLEGAL_STATE_EXCEPTION_MESSAGE = "Something else went wrong"; + @RegisterExtension + static QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(TestApi.class) + .addAsManifestResource(EmptyAsset.INSTANCE, "beans.xml") + .addAsResource( + new StringAsset( + "quarkus.smallrye-graphql.show-runtime-exception-message=" + + "java.lang.IllegalArgumentException," + + "java.lang.IllegalStateException"), + "application.properties")); + + @Test + void testExcludeNullFieldsInResponse() { + given() + .when() + .accept(MEDIATYPE_JSON) + .contentType(MEDIATYPE_JSON) + .body(getPayload("{ something }")) + .post("/graphql") + .then() + .assertThat() + .statusCode(OK) + .and() + .body(containsString(ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE)); + + given() + .when() + .accept(MEDIATYPE_JSON) + .contentType(MEDIATYPE_JSON) + .body(getPayload("{ somethingElse }")) + .post("/graphql") + .then() + .assertThat() + .statusCode(OK) + .and() + .body(containsString(ILLEGAL_STATE_EXCEPTION_MESSAGE)); + } + + @GraphQLApi + public static class TestApi { + @Query + public String getSomething() { + throw new IllegalArgumentException(ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE); + } + + @Query + public String getSomethingElse() { + throw new IllegalStateException(ILLEGAL_STATE_EXCEPTION_MESSAGE); + } + } +} diff --git a/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLConfig.java b/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLConfig.java index 0dcc17601d7fa..74d4b382698a9 100644 --- a/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLConfig.java +++ b/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLConfig.java @@ -83,20 +83,6 @@ public class SmallRyeGraphQLConfig { @ConfigItem public Optional> errorExtensionFields; - /** - * List of Runtime Exceptions class names that should show the error message. - * By default, Runtime Exception messages will be hidden and a generic `Server Error` message will be returned. - */ - @ConfigItem - public Optional> showRuntimeExceptionMessage; - - /** - * List of Checked Exceptions class names that should hide the error message. - * By default, Checked Exception messages will show the exception message. - */ - @ConfigItem - public Optional> hideCheckedExceptionMessage; - /** * The default error message that will be used for hidden exception messages. * Defaults to "Server Error" @@ -215,12 +201,4 @@ public class SmallRyeGraphQLConfig { */ @ConfigItem public Optional> extraScalars; - - /** - * Excludes all the 'null' fields in the GraphQL response's data field, - * except for the non-successfully resolved fields (errors). - * Disabled by default. - */ - @ConfigItem - public Optional excludeNullFieldsInResponses; } diff --git a/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLConfigMapping.java b/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLConfigMapping.java index f5a41dc4f494c..f78f20bd66f25 100644 --- a/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLConfigMapping.java +++ b/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLConfigMapping.java @@ -41,6 +41,7 @@ private static Map relocations() { mapKey(relocations, ConfigKey.ALLOW_GET, QUARKUS_HTTP_GET_ENABLED); mapKey(relocations, ConfigKey.ALLOW_POST_WITH_QUERY_PARAMETERS, QUARKUS_HTTP_POST_QUERYPARAMETERS_ENABLED); mapKey(relocations, ConfigKey.ERROR_EXTENSION_FIELDS, QUARKUS_ERROR_EXTENSION_FIELDS); + mapKey(relocations, ConfigKey.EXCLUDE_NULL_FIELDS_IN_RESPONSES, QUARKUS_EXCLUDE_NULL_FIELDS_IN_RESPONSES); mapKey(relocations, ConfigKey.DEFAULT_ERROR_MESSAGE, QUARKUS_DEFAULT_ERROR_MESSAGE); mapKey(relocations, ConfigKey.SCHEMA_INCLUDE_SCALARS, QUARKUS_SCHEMA_INCLUDE_SCALARS); mapKey(relocations, ConfigKey.SCHEMA_INCLUDE_DEFINITION, QUARKUS_SCHEMA_INCLUDE_DEFINITION); @@ -69,6 +70,7 @@ private static void mapKey(Map map, String quarkusKey, String ot private static final String SHOW_ERROR_MESSAGE = "mp.graphql.showErrorMessage"; private static final String HIDE_ERROR_MESSAGE = "mp.graphql.hideErrorMessage"; private static final String QUARKUS_ERROR_EXTENSION_FIELDS = "quarkus.smallrye-graphql.error-extension-fields"; + private static final String QUARKUS_EXCLUDE_NULL_FIELDS_IN_RESPONSES = "quarkus.smallrye-graphql.exclude-null-fields-in-responses"; private static final String QUARKUS_DEFAULT_ERROR_MESSAGE = "quarkus.smallrye-graphql.default-error-message"; private static final String QUARKUS_SHOW_ERROR_MESSAGE = "quarkus.smallrye-graphql.show-runtime-exception-message"; private static final String QUARKUS_HIDE_ERROR_MESSAGE = "quarkus.smallrye-graphql.hide-checked-exception-message"; diff --git a/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLRuntimeConfig.java b/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLRuntimeConfig.java index 9cd45806c253f..e74025abcb765 100644 --- a/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLRuntimeConfig.java +++ b/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLRuntimeConfig.java @@ -1,5 +1,8 @@ package io.quarkus.smallrye.graphql.runtime; +import java.util.List; +import java.util.Optional; + import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; @@ -23,4 +26,26 @@ public class SmallRyeGraphQLRuntimeConfig { */ @ConfigItem(defaultValue = "default") public String fieldVisibility; + + /** + * Excludes all the 'null' fields in the GraphQL response's data field, + * except for the non-successfully resolved fields (errors). + * Disabled by default. + */ + @ConfigItem + public Optional excludeNullFieldsInResponses; + + /** + * List of Runtime Exceptions class names that should show the error message. + * By default, Runtime Exception messages will be hidden and a generic `Server Error` message will be returned. + */ + @ConfigItem + public Optional> showRuntimeExceptionMessage; + + /** + * List of Checked Exceptions class names that should hide the error message. + * By default, Checked Exception messages will show the exception message. + */ + @ConfigItem + public Optional> hideCheckedExceptionMessage; } diff --git a/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/SmallRyeOpenApiProcessor.java b/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/SmallRyeOpenApiProcessor.java index 00d2a9ad6c697..8f46b46da752a 100644 --- a/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/SmallRyeOpenApiProcessor.java +++ b/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/SmallRyeOpenApiProcessor.java @@ -595,7 +595,7 @@ private Map> getRolesAllowedMethodReferences(OpenApiFiltere .flatMap(Collection::stream) .flatMap(t -> getMethods(t, index)) .collect(Collectors.toMap( - e -> createUniqueMethodReference(e.getKey().declaringClass(), e.getKey()), + e -> createUniqueMethodReference(e.getKey().classInfo(), e.getKey().method()), e -> List.of(e.getValue().value().asStringArray()), (v1, v2) -> { if (!Objects.equals(v1, v2)) { @@ -615,7 +615,7 @@ private List getPermissionsAllowedMethodReferences( .getAnnotations(DotName.createSimple(PermissionsAllowed.class)) .stream() .flatMap(t -> getMethods(t, index)) - .map(e -> createUniqueMethodReference(e.getKey().declaringClass(), e.getKey())) + .map(e -> createUniqueMethodReference(e.getKey().classInfo(), e.getKey().method())) .distinct() .toList(); } @@ -626,18 +626,18 @@ private List getAuthenticatedMethodReferences(OpenApiFilteredIndexViewBu .getAnnotations(DotName.createSimple(Authenticated.class.getName())) .stream() .flatMap(t -> getMethods(t, index)) - .map(e -> createUniqueMethodReference(e.getKey().declaringClass(), e.getKey())) + .map(e -> createUniqueMethodReference(e.getKey().classInfo(), e.getKey().method())) .distinct() .toList(); } - private static Stream> getMethods(AnnotationInstance annotation, + private static Stream> getMethods(AnnotationInstance annotation, IndexView index) { if (annotation.target().kind() == Kind.METHOD) { MethodInfo method = annotation.target().asMethod(); if (isValidOpenAPIMethodForAutoAdd(method)) { - return Stream.of(Map.entry(method, annotation)); + return Stream.of(Map.entry(new ClassAndMethod(method.declaringClass(), method), annotation)); } } else if (annotation.target().kind() == Kind.CLASS) { ClassInfo classInfo = annotation.target().asClass(); @@ -647,7 +647,23 @@ private static Stream> getMethods(Anno // drop methods that specify the annotation directly .filter(method -> !method.hasDeclaredAnnotation(annotation.name())) .filter(method -> isValidOpenAPIMethodForAutoAdd(method)) - .map(method -> Map.entry(method, annotation)); + .map(method -> { + final ClassInfo resourceClass; + + if (method.declaringClass().isInterface()) { + /* + * smallrye-open-api processes interfaces as the resource class as long as + * there is a concrete implementation available. Using the interface method's + * declaring class here allows us to match on the hash that will be set by + * #handleOperation during scanning. + */ + resourceClass = method.declaringClass(); + } else { + resourceClass = classInfo; + } + + return Map.entry(new ClassAndMethod(resourceClass, method), annotation); + }); } return Stream.empty(); @@ -678,7 +694,7 @@ private Map getClassNamesMethodReferences( .getAllKnownSubclasses(declaringClass.name()), classNames); } else { String ref = createUniqueMethodReference(declaringClass, method); - classNames.put(ref, new ClassAndMethod(declaringClass.simpleName(), method.name())); + classNames.put(ref, new ClassAndMethod(declaringClass, method)); } } } @@ -688,16 +704,15 @@ private Map getClassNamesMethodReferences( void addMethodImplementationClassNames(MethodInfo method, Type[] params, Collection classes, Map classNames) { for (ClassInfo impl : classes) { - String simpleClassName = impl.simpleName(); MethodInfo implMethod = impl.method(method.name(), params); if (implMethod != null) { classNames.put(createUniqueMethodReference(impl, implMethod), - new ClassAndMethod(simpleClassName, implMethod.name())); + new ClassAndMethod(impl, implMethod)); } classNames.put(createUniqueMethodReference(impl, method), - new ClassAndMethod(simpleClassName, method.name())); + new ClassAndMethod(impl, method)); } } @@ -769,7 +784,6 @@ private static boolean isOpenAPIEndpoint(MethodInfo method) { } private static List getMethods(ClassInfo declaringClass, IndexView index) { - List methods = new ArrayList<>(); methods.addAll(declaringClass.methods()); @@ -782,8 +796,17 @@ private static List getMethods(ClassInfo declaringClass, IndexView i } } } - return methods; + DotName superClassName = declaringClass.superName(); + if (superClassName != null) { + ClassInfo superClass = index.getClassByName(superClassName); + + if (superClass != null) { + methods.addAll(getMethods(superClass, index)); + } + } + + return methods; } private static Set getAllOpenAPIEndpoints() { diff --git a/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/ClassAndMethod.java b/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/ClassAndMethod.java index d5f1bce941d38..737adf0d6d5b8 100644 --- a/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/ClassAndMethod.java +++ b/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/ClassAndMethod.java @@ -1,5 +1,8 @@ package io.quarkus.smallrye.openapi.deployment.filter; -public record ClassAndMethod(String className, String methodName) { +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.MethodInfo; + +public record ClassAndMethod(ClassInfo classInfo, MethodInfo method) { } diff --git a/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/OperationFilter.java b/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/OperationFilter.java index 640a919febf5d..296a2c4746e5a 100644 --- a/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/OperationFilter.java +++ b/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/OperationFilter.java @@ -106,11 +106,11 @@ private void maybeSetSummaryAndTag(Operation operation, String methodRef) { if (doAutoOperation && operation.getSummary() == null) { // Auto add a summary - operation.setSummary(capitalizeFirstLetter(splitCamelCase(classMethod.methodName()))); + operation.setSummary(capitalizeFirstLetter(splitCamelCase(classMethod.method().name()))); } if (doAutoTag && (operation.getTags() == null || operation.getTags().isEmpty())) { - operation.addTag(splitCamelCase(classMethod.className())); + operation.addTag(splitCamelCase(classMethod.classInfo().simpleName())); } } diff --git a/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/AutoSecurityAuthenticateTestCase.java b/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/AutoSecurityAuthenticateTestCase.java index ff27f0614a282..76e542bdf0b52 100644 --- a/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/AutoSecurityAuthenticateTestCase.java +++ b/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/AutoSecurityAuthenticateTestCase.java @@ -1,5 +1,7 @@ package io.quarkus.smallrye.openapi.test.jaxrs; +import static org.hamcrest.Matchers.notNullValue; + import org.hamcrest.Matchers; import org.jboss.shrinkwrap.api.asset.StringAsset; import org.junit.jupiter.api.Test; @@ -8,50 +10,50 @@ import io.quarkus.test.QuarkusUnitTest; import io.restassured.RestAssured; -public class AutoSecurityAuthenticateTestCase { +class AutoSecurityAuthenticateTestCase { + @RegisterExtension static QuarkusUnitTest runner = new QuarkusUnitTest() .withApplicationRoot((jar) -> jar .addClasses(ResourceBean2.class, OpenApiResourceAuthenticatedAtClassLevel.class, + OpenApiResourceAuthenticatedInherited1.class, OpenApiResourceAuthenticatedInherited2.class, OpenApiResourceAuthenticatedAtMethodLevel.class, OpenApiResourceAuthenticatedAtMethodLevel2.class) .addAsResource( - new StringAsset("quarkus.smallrye-openapi.security-scheme=jwt\n" - + "quarkus.smallrye-openapi.security-scheme-name=JWTCompanyAuthentication\n" - + "quarkus.smallrye-openapi.security-scheme-description=JWT Authentication"), - + new StringAsset(""" + quarkus.smallrye-openapi.security-scheme=jwt + quarkus.smallrye-openapi.security-scheme-name=JWTCompanyAuthentication + quarkus.smallrye-openapi.security-scheme-description=JWT Authentication + """), "application.properties")); @Test - public void testAutoSecurityRequirement() { + void testAutoSecurityRequirement() { RestAssured.given().header("Accept", "application/json") .when().get("/q/openapi") .then() .log().body() .and() - .body("components.securitySchemes.JWTCompanyAuthentication", Matchers.hasEntry("type", "http")) - .and() - .body("components.securitySchemes.JWTCompanyAuthentication", - Matchers.hasEntry("description", "JWT Authentication")) - .and() - .body("components.securitySchemes.JWTCompanyAuthentication", Matchers.hasEntry("scheme", "bearer")) - .and() - .body("components.securitySchemes.JWTCompanyAuthentication", Matchers.hasEntry("bearerFormat", "JWT")) - .and() - .body("paths.'/resource2/test-security/annotated'.get.security.JWTCompanyAuthentication", - Matchers.notNullValue()) - .and() - .body("paths.'/resource2/test-security/naked'.get.security.JWTCompanyAuthentication", Matchers.notNullValue()) - .and() - .body("paths.'/resource2/test-security/classLevel/1'.get.security.JWTCompanyAuthentication", - Matchers.notNullValue()) - .and() - .body("paths.'/resource2/test-security/classLevel/2'.get.security.JWTCompanyAuthentication", - Matchers.notNullValue()) - .and() - .body("paths.'/resource2/test-security/classLevel/3'.get.security.MyOwnName", - Matchers.notNullValue()) - .and() - .body("paths.'/resource3/test-security/annotated'.get.security.AtClassLevel", Matchers.notNullValue()); + .body("components.securitySchemes.JWTCompanyAuthentication", Matchers.allOf( + Matchers.hasEntry("type", "http"), + Matchers.hasEntry("description", "JWT Authentication"), + Matchers.hasEntry("scheme", "bearer"), + Matchers.hasEntry("bearerFormat", "JWT"))) + .body("paths.'/resource2/test-security/annotated'.get.security.JWTCompanyAuthentication", notNullValue()) + .body("paths.'/resource2/test-security/naked'.get.security.JWTCompanyAuthentication", notNullValue()) + .body("paths.'/resource2/test-security/classLevel/1'.get.security.JWTCompanyAuthentication", notNullValue()) + .body("paths.'/resource2/test-security/classLevel/2'.get.security.JWTCompanyAuthentication", notNullValue()) + .body("paths.'/resource2/test-security/classLevel/3'.get.security.MyOwnName", notNullValue()) + .body("paths.'/resource3/test-security/annotated'.get.security.AtClassLevel", notNullValue()) + .body("paths.'/resource-inherited1/test-security/classLevel/1'.get.security.JWTCompanyAuthentication", + notNullValue()) + .body("paths.'/resource-inherited1/test-security/classLevel/2'.get.security.JWTCompanyAuthentication", + notNullValue()) + .body("paths.'/resource-inherited1/test-security/classLevel/3'.get.security.MyOwnName", notNullValue()) + .body("paths.'/resource-inherited2/test-security/classLevel/1'.get.security.JWTCompanyAuthentication", + notNullValue()) + .body("paths.'/resource-inherited2/test-security/classLevel/2'.get.security.CustomOverride", + notNullValue()) + .body("paths.'/resource-inherited2/test-security/classLevel/3'.get.security.MyOwnName", notNullValue()); } diff --git a/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/OpenApiResourceAuthenticatedInherited1.java b/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/OpenApiResourceAuthenticatedInherited1.java new file mode 100644 index 0000000000000..c36256db65adb --- /dev/null +++ b/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/OpenApiResourceAuthenticatedInherited1.java @@ -0,0 +1,15 @@ +package io.quarkus.smallrye.openapi.test.jaxrs; + +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.openapi.annotations.servers.Server; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +import io.quarkus.security.Authenticated; + +@Path("/resource-inherited1") +@Tag(name = "test") +@Server(url = "serverUrl") +@Authenticated +public class OpenApiResourceAuthenticatedInherited1 extends OpenApiResourceAuthenticatedAtClassLevel { +} diff --git a/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/OpenApiResourceAuthenticatedInherited2.java b/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/OpenApiResourceAuthenticatedInherited2.java new file mode 100644 index 0000000000000..8e03fcb10b884 --- /dev/null +++ b/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/OpenApiResourceAuthenticatedInherited2.java @@ -0,0 +1,25 @@ +package io.quarkus.smallrye.openapi.test.jaxrs; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.servers.Server; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +import io.quarkus.security.Authenticated; + +@Path("/resource-inherited2") +@Tag(name = "test") +@Server(url = "serverUrl") +@Authenticated +public class OpenApiResourceAuthenticatedInherited2 extends OpenApiResourceAuthenticatedInherited1 { + + @GET + @Path("/test-security/classLevel/2") + @SecurityRequirement(name = "CustomOverride") + public String secureEndpoint2() { + return "secret"; + } + +} diff --git a/extensions/smallrye-reactive-messaging-pulsar/runtime/src/main/java/io/quarkus/pulsar/PulsarClientConfigCustomizer.java b/extensions/smallrye-reactive-messaging-pulsar/runtime/src/main/java/io/quarkus/pulsar/PulsarClientConfigCustomizer.java index 07e04f1cb9e74..da69afed37ebb 100644 --- a/extensions/smallrye-reactive-messaging-pulsar/runtime/src/main/java/io/quarkus/pulsar/PulsarClientConfigCustomizer.java +++ b/extensions/smallrye-reactive-messaging-pulsar/runtime/src/main/java/io/quarkus/pulsar/PulsarClientConfigCustomizer.java @@ -13,6 +13,7 @@ import io.quarkus.tls.TlsConfiguration; import io.quarkus.tls.TlsConfigurationRegistry; +import io.quarkus.tls.runtime.keystores.ExpiryTrustOptions; import io.smallrye.reactive.messaging.ClientCustomizer; import io.vertx.core.buffer.Buffer; import io.vertx.core.net.KeyCertOptions; @@ -46,6 +47,10 @@ public ClientBuilder customize(String channel, Config channelConfig, ClientBuild KeyCertOptions keyStoreOptions = configuration.getKeyStoreOptions(); TrustOptions trustStoreOptions = configuration.getTrustStoreOptions(); + if (trustStoreOptions instanceof ExpiryTrustOptions) { + trustStoreOptions = ((ExpiryTrustOptions) trustStoreOptions).unwrap(); + } + if (keyStoreOptions instanceof PemKeyCertOptions keyCertOptions && trustStoreOptions instanceof PemTrustOptions trustCertOptions) { Buffer trust = trustCertOptions.getCertValues().stream() diff --git a/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfig.java b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfig.java index 1f6db20c48b8e..dcca34587c9c0 100644 --- a/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfig.java +++ b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfig.java @@ -125,6 +125,12 @@ public interface SpringCloudConfigClientConfig { */ Optional> profiles(); + /** + * Microprofile Config ordinal. + */ + @WithDefault("450") + int ordinal(); + /** */ default boolean usernameAndPasswordSet() { return username().isPresent() && password().isPresent(); diff --git a/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfigSourceFactory.java b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfigSourceFactory.java index 509dddfed30ad..2d649b250a8d7 100644 --- a/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfigSourceFactory.java +++ b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfigSourceFactory.java @@ -73,7 +73,7 @@ public Iterable getConfigSources(final ConfigSourceContext context log.debug("Obtained " + responses.size() + " from the config server"); - int ordinal = 450; + int ordinal = config.ordinal(); // Profiles are looked from the highest ordinal to lowest, so we reverse the collection to build the source list Collections.reverse(responses); for (Response response : responses) { diff --git a/extensions/spring-cloud-config-client/runtime/src/test/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfigSourceFactoryTest.java b/extensions/spring-cloud-config-client/runtime/src/test/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfigSourceFactoryTest.java index 384bf24e3c777..a0bc30936c3e3 100644 --- a/extensions/spring-cloud-config-client/runtime/src/test/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfigSourceFactoryTest.java +++ b/extensions/spring-cloud-config-client/runtime/src/test/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfigSourceFactoryTest.java @@ -47,7 +47,7 @@ void testExtensionDisabled() { // Arrange final ConfigSourceContext context = Mockito.mock(ConfigSourceContext.class); - final SpringCloudConfigClientConfig config = configForTesting(false, "foo", MOCK_SERVER_PORT, true); + final SpringCloudConfigClientConfig config = configForTesting(false, "foo", MOCK_SERVER_PORT, true, 450); final SpringCloudConfigClientConfigSourceFactory factory = new SpringCloudConfigClientConfigSourceFactory(); // Act @@ -62,7 +62,7 @@ void testNameNotProvided() { // Arrange final ConfigSourceContext context = Mockito.mock(ConfigSourceContext.class); - final SpringCloudConfigClientConfig config = configForTesting(true, null, MOCK_SERVER_PORT, true); + final SpringCloudConfigClientConfig config = configForTesting(true, null, MOCK_SERVER_PORT, true, 450); final SpringCloudConfigClientConfigSourceFactory factory = new SpringCloudConfigClientConfigSourceFactory(); // Act @@ -77,7 +77,7 @@ void testInAppCDsGeneration() { // Arrange final ConfigSourceContext context = Mockito.mock(ConfigSourceContext.class); - final SpringCloudConfigClientConfig config = configForTesting(true, "foo", MOCK_SERVER_PORT, true); + final SpringCloudConfigClientConfig config = configForTesting(true, "foo", MOCK_SERVER_PORT, true, 450); final SpringCloudConfigClientConfigSourceFactory factory = new SpringCloudConfigClientConfigSourceFactory(); System.setProperty(ApplicationLifecycleManager.QUARKUS_APPCDS_GENERATE_PROP, "true"); @@ -97,7 +97,7 @@ void testFailFastDisable() { // Arrange final ConfigSourceContext context = Mockito.mock(ConfigSourceContext.class); - final SpringCloudConfigClientConfig config = configForTesting(true, "unknown-application", 1234, false); + final SpringCloudConfigClientConfig config = configForTesting(true, "unknown-application", 1234, false, 450); final SpringCloudConfigClientConfigSourceFactory factory = new SpringCloudConfigClientConfigSourceFactory(); Mockito.when(context.getProfiles()).thenReturn(List.of("dev")); @@ -114,7 +114,7 @@ void testFailFastEnabled() { // Arrange final ConfigSourceContext context = Mockito.mock(ConfigSourceContext.class); - final SpringCloudConfigClientConfig config = configForTesting(true, "unknown-application", 1234, true); + final SpringCloudConfigClientConfig config = configForTesting(true, "unknown-application", 1234, true, 450); final SpringCloudConfigClientConfigSourceFactory factory = new SpringCloudConfigClientConfigSourceFactory(); Mockito.when(context.getProfiles()).thenReturn(List.of("dev")); @@ -130,7 +130,7 @@ void testBasic() throws IOException { // Arrange final String profile = "dev"; final ConfigSourceContext context = Mockito.mock(ConfigSourceContext.class); - final SpringCloudConfigClientConfig config = configForTesting(true, "foo", MOCK_SERVER_PORT, true); + final SpringCloudConfigClientConfig config = configForTesting(true, "foo", MOCK_SERVER_PORT, true, 450); final SpringCloudConfigClientConfigSourceFactory factory = new SpringCloudConfigClientConfigSourceFactory(); Mockito.when(context.getProfiles()).thenReturn(List.of(profile)); @@ -176,7 +176,7 @@ void testBasic() throws IOException { } private SpringCloudConfigClientConfig configForTesting(final boolean isEnabled, final String appName, - final int serverPort, final boolean isFailFastEnabled) { + final int serverPort, final boolean isFailFastEnabled, final int ordinal) { final SpringCloudConfigClientConfig config = Mockito.mock(SpringCloudConfigClientConfig.class); when(config.enabled()).thenReturn(isEnabled); @@ -192,6 +192,7 @@ private SpringCloudConfigClientConfig configForTesting(final boolean isEnabled, when(config.keyStore()).thenReturn(Optional.empty()); when(config.trustCerts()).thenReturn(false); when(config.headers()).thenReturn(new HashMap<>()); + when(config.ordinal()).thenReturn(ordinal); return config; } diff --git a/extensions/spring-cloud-config-client/runtime/src/test/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientGatewayTest.java b/extensions/spring-cloud-config-client/runtime/src/test/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientGatewayTest.java index 1a8f492534ba1..848c1b69c3892 100644 --- a/extensions/spring-cloud-config-client/runtime/src/test/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientGatewayTest.java +++ b/extensions/spring-cloud-config-client/runtime/src/test/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientGatewayTest.java @@ -86,6 +86,7 @@ private static SpringCloudConfigClientConfig configForTesting() { when(config.keyStore()).thenReturn(Optional.empty()); when(config.trustCerts()).thenReturn(false); when(config.headers()).thenReturn(new HashMap<>()); + when(config.ordinal()).thenReturn(450); return config; } } diff --git a/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/SpringDataJPAProcessor.java b/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/SpringDataJPAProcessor.java index 104d9167067b7..2b893982be4f0 100644 --- a/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/SpringDataJPAProcessor.java +++ b/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/SpringDataJPAProcessor.java @@ -103,6 +103,7 @@ void registerReflection(BuildProducer producer) { "org.springframework.data.domain.Page", "org.springframework.data.domain.Slice", "org.springframework.data.domain.PageImpl", + "org.springframework.data.domain.Pageable", "org.springframework.data.domain.SliceImpl", "org.springframework.data.domain.Sort", "org.springframework.data.domain.Chunk", diff --git a/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/SpringWebProcessor.java b/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/SpringWebProcessor.java index 4699cc9593141..1c01a383fd1ab 100644 --- a/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/SpringWebProcessor.java +++ b/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/SpringWebProcessor.java @@ -6,6 +6,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.function.Predicate; import jakarta.ws.rs.Priorities; import jakarta.ws.rs.core.HttpHeaders; @@ -34,6 +35,7 @@ import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyIgnoreWarningBuildItem; import io.quarkus.gizmo.ClassOutput; import io.quarkus.jaxrs.spi.deployment.AdditionalJaxRsResourceMethodAnnotationsBuildItem; +import io.quarkus.resteasy.common.spi.EndpointValidationPredicatesBuildItem; import io.quarkus.resteasy.common.spi.ResteasyJaxrsProviderBuildItem; import io.quarkus.resteasy.reactive.spi.ExceptionMapperBuildItem; @@ -86,6 +88,30 @@ public AdditionalJaxRsResourceMethodAnnotationsBuildItem additionalJaxRsResource return new AdditionalJaxRsResourceMethodAnnotationsBuildItem(MAPPING_ANNOTATIONS); } + @BuildStep + EndpointValidationPredicatesBuildItem createSpringRestControllerPredicateForClassic() { + Predicate predicate = new Predicate<>() { + @Override + public boolean test(ClassInfo classInfo) { + return classInfo + .declaredAnnotation(REST_CONTROLLER_ANNOTATION) == null; + } + }; + return new EndpointValidationPredicatesBuildItem(predicate); + } + + @BuildStep + io.quarkus.resteasy.reactive.spi.EndpointValidationPredicatesBuildItem createSpringRestControllerPredicateForReactive() { + Predicate predicate = new Predicate<>() { + @Override + public boolean test(ClassInfo classInfo) { + return classInfo + .declaredAnnotation(REST_CONTROLLER_ANNOTATION) == null; + } + }; + return new io.quarkus.resteasy.reactive.spi.EndpointValidationPredicatesBuildItem(predicate); + } + @BuildStep public void ignoreReflectionHierarchy(BuildProducer ignore) { ignore.produce(new ReflectiveHierarchyIgnoreWarningBuildItem( diff --git a/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/resteasy/reactive/test/UnbuildableSpringRestControllerInterfaceTest.java b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/resteasy/reactive/test/UnbuildableSpringRestControllerInterfaceTest.java new file mode 100644 index 0000000000000..9cdb6b44c8779 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/resteasy/reactive/test/UnbuildableSpringRestControllerInterfaceTest.java @@ -0,0 +1,48 @@ +package io.quarkus.spring.web.resteasy.reactive.test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import jakarta.ws.rs.QueryParam; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import io.quarkus.test.QuarkusProdModeTest; + +public class UnbuildableSpringRestControllerInterfaceTest { + + @RegisterExtension + static final QuarkusProdModeTest config = new QuarkusProdModeTest() + .withApplicationRoot((jar) -> jar + .addClasses(UnbuildableControllerInterface.class)) + .setApplicationName("unbuildable-rest-controller-interface") + .setApplicationVersion("0.1-SNAPSHOT") + .assertBuildException(throwable -> { + assertThat(throwable).isInstanceOf(RuntimeException.class); + assertThat(throwable).hasMessageContaining( + "Cannot have more than one of @PathParam, @QueryParam, @HeaderParam, @FormParam, @CookieParam, @BeanParam, @Context on method"); + }); + + @Test + public void testBuildLogs() { + fail("Should not be called"); + } + + @RestController + @RequestMapping("/unbuildable") + public interface UnbuildableControllerInterface { + @GetMapping("/ping") + String ping(); + + @PostMapping("/hello") + String hello(@RequestParam(required = false) @QueryParam("dung") String params); + + } + +} diff --git a/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredJKSTrustStoreTest.java b/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredJKSTrustStoreTest.java new file mode 100644 index 0000000000000..b421a4cec129c --- /dev/null +++ b/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredJKSTrustStoreTest.java @@ -0,0 +1,100 @@ +package io.quarkus.tls; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.CountDownLatch; + +import javax.net.ssl.SSLHandshakeException; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import io.smallrye.certs.junit5.Certificates; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; + +@Certificates(baseDir = "target/certs", certificates = { + @Certificate(name = "expired-test-formats", password = "password", formats = { Format.JKS, Format.PEM, + Format.PKCS12 }, duration = -5) +}) +public class ExpiredJKSTrustStoreTest { + + private static final String configuration = """ + # Server + quarkus.tls.key-store.jks.path=target/certs/expired-test-formats-keystore.jks + quarkus.tls.key-store.jks.password=password + + # Clients + quarkus.tls.warn.trust-store.jks.path=target/certs/expired-test-formats-truststore.jks + quarkus.tls.warn.trust-store.jks.password=password + quarkus.tls.warn.trust-store.certificate-expiration-policy=warn + + quarkus.tls.reject.trust-store.jks.path=target/certs/expired-test-formats-truststore.jks + quarkus.tls.reject.trust-store.jks.password=password + quarkus.tls.reject.trust-store.certificate-expiration-policy=reject + """; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest().setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .add(new StringAsset(configuration), "application.properties")); + + @Inject + TlsConfigurationRegistry certificates; + + @Inject + Vertx vertx; + + @Test + void testWarn() throws InterruptedException { + TlsConfiguration cf = certificates.get("warn").orElseThrow(); + assertThat(cf.getTrustStoreOptions()).isNotNull(); + + WebClient client = WebClient.create(vertx, new WebClientOptions() + .setSsl(true) + .setTrustOptions(cf.getTrustStoreOptions())); + + vertx.createHttpServer(new HttpServerOptions() + .setSsl(true) + .setKeyCertOptions(certificates.getDefault().orElseThrow().getKeyStoreOptions())) + .requestHandler(rc -> rc.response().end("Hello")).listen(8081).toCompletionStage().toCompletableFuture().join(); + + CountDownLatch latch = new CountDownLatch(1); + client.get(8081, "localhost", "/").send(ar -> { + assertThat(ar.succeeded()).isTrue(); + assertThat(ar.result().bodyAsString()).isEqualTo("Hello"); + latch.countDown(); + }); + + assertThat(latch.await(10, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + } + + @Test + void testReject() { + TlsConfiguration cf = certificates.get("reject").orElseThrow(); + assertThat(cf.getTrustStoreOptions()).isNotNull(); + + WebClient client = WebClient.create(vertx, new WebClientOptions() + .setSsl(true) + .setTrustOptions(cf.getTrustStoreOptions())); + + vertx.createHttpServer(new HttpServerOptions() + .setSsl(true) + .setKeyCertOptions(certificates.getDefault().orElseThrow().getKeyStoreOptions())) + .requestHandler(rc -> rc.response().end("Hello")).listen(8081).toCompletionStage().toCompletableFuture().join(); + + assertThatThrownBy(() -> client.get(8081, "localhost", "/") + .send().toCompletionStage().toCompletableFuture().join()).hasCauseInstanceOf(SSLHandshakeException.class); + } +} diff --git a/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredP12TrustStoreTest.java b/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredP12TrustStoreTest.java new file mode 100644 index 0000000000000..dafb2dce66bd5 --- /dev/null +++ b/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredP12TrustStoreTest.java @@ -0,0 +1,100 @@ +package io.quarkus.tls; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.CountDownLatch; + +import javax.net.ssl.SSLHandshakeException; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import io.smallrye.certs.junit5.Certificates; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; + +@Certificates(baseDir = "target/certs", certificates = { + @Certificate(name = "expired-test-formats", password = "password", formats = { Format.JKS, Format.PEM, + Format.PKCS12 }, duration = -5) +}) +public class ExpiredP12TrustStoreTest { + + private static final String configuration = """ + # Server + quarkus.tls.key-store.p12.path=target/certs/expired-test-formats-keystore.p12 + quarkus.tls.key-store.p12.password=password + + # Clients + quarkus.tls.warn.trust-store.p12.path=target/certs/expired-test-formats-truststore.p12 + quarkus.tls.warn.trust-store.p12.password=password + quarkus.tls.warn.trust-store.certificate-expiration-policy=warn + + quarkus.tls.reject.trust-store.p12.path=target/certs/expired-test-formats-truststore.p12 + quarkus.tls.reject.trust-store.p12.password=password + quarkus.tls.reject.trust-store.certificate-expiration-policy=reject + """; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest().setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .add(new StringAsset(configuration), "application.properties")); + + @Inject + TlsConfigurationRegistry certificates; + + @Inject + Vertx vertx; + + @Test + void testWarn() throws InterruptedException { + TlsConfiguration cf = certificates.get("warn").orElseThrow(); + assertThat(cf.getTrustStoreOptions()).isNotNull(); + + WebClient client = WebClient.create(vertx, new WebClientOptions() + .setSsl(true) + .setTrustOptions(cf.getTrustStoreOptions())); + + vertx.createHttpServer(new HttpServerOptions() + .setSsl(true) + .setKeyCertOptions(certificates.getDefault().orElseThrow().getKeyStoreOptions())) + .requestHandler(rc -> rc.response().end("Hello")).listen(8081).toCompletionStage().toCompletableFuture().join(); + + CountDownLatch latch = new CountDownLatch(1); + client.get(8081, "localhost", "/").send(ar -> { + assertThat(ar.succeeded()).isTrue(); + assertThat(ar.result().bodyAsString()).isEqualTo("Hello"); + latch.countDown(); + }); + + assertThat(latch.await(10, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + } + + @Test + void testReject() { + TlsConfiguration cf = certificates.get("reject").orElseThrow(); + assertThat(cf.getTrustStoreOptions()).isNotNull(); + + WebClient client = WebClient.create(vertx, new WebClientOptions() + .setSsl(true) + .setTrustOptions(cf.getTrustStoreOptions())); + + vertx.createHttpServer(new HttpServerOptions() + .setSsl(true) + .setKeyCertOptions(certificates.getDefault().orElseThrow().getKeyStoreOptions())) + .requestHandler(rc -> rc.response().end("Hello")).listen(8081).toCompletionStage().toCompletableFuture().join(); + + assertThatThrownBy(() -> client.get(8081, "localhost", "/") + .send().toCompletionStage().toCompletableFuture().join()).hasCauseInstanceOf(SSLHandshakeException.class); + } +} diff --git a/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredPemTrustStoreTest.java b/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredPemTrustStoreTest.java new file mode 100644 index 0000000000000..7324667f18623 --- /dev/null +++ b/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredPemTrustStoreTest.java @@ -0,0 +1,98 @@ +package io.quarkus.tls; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.CountDownLatch; + +import javax.net.ssl.SSLHandshakeException; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import io.smallrye.certs.junit5.Certificates; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; + +@Certificates(baseDir = "target/certs", certificates = { + @Certificate(name = "expired-test-formats", password = "password", formats = { Format.JKS, Format.PEM, + Format.PKCS12 }, duration = -5) +}) +public class ExpiredPemTrustStoreTest { + + private static final String configuration = """ + # Server + quarkus.tls.key-store.p12.path=target/certs/expired-test-formats-keystore.p12 + quarkus.tls.key-store.p12.password=password + + # Clients + quarkus.tls.warn.trust-store.pem.certs=target/certs/expired-test-formats.crt + quarkus.tls.warn.trust-store.certificate-expiration-policy=warn + + quarkus.tls.reject.trust-store.pem.certs=target/certs/expired-test-formats.crt + quarkus.tls.reject.trust-store.certificate-expiration-policy=reject + """; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest().setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .add(new StringAsset(configuration), "application.properties")); + + @Inject + TlsConfigurationRegistry certificates; + + @Inject + Vertx vertx; + + @Test + void testWarn() throws InterruptedException { + TlsConfiguration cf = certificates.get("warn").orElseThrow(); + assertThat(cf.getTrustStoreOptions()).isNotNull(); + + WebClient client = WebClient.create(vertx, new WebClientOptions() + .setSsl(true) + .setTrustOptions(cf.getTrustStoreOptions())); + + vertx.createHttpServer(new HttpServerOptions() + .setSsl(true) + .setKeyCertOptions(certificates.getDefault().orElseThrow().getKeyStoreOptions())) + .requestHandler(rc -> rc.response().end("Hello")).listen(8081).toCompletionStage().toCompletableFuture().join(); + + CountDownLatch latch = new CountDownLatch(1); + client.get(8081, "localhost", "/").send(ar -> { + assertThat(ar.succeeded()).isTrue(); + assertThat(ar.result().bodyAsString()).isEqualTo("Hello"); + latch.countDown(); + }); + + assertThat(latch.await(10, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + } + + @Test + void testReject() { + TlsConfiguration cf = certificates.get("reject").orElseThrow(); + assertThat(cf.getTrustStoreOptions()).isNotNull(); + + WebClient client = WebClient.create(vertx, new WebClientOptions() + .setSsl(true) + .setTrustOptions(cf.getTrustStoreOptions())); + + vertx.createHttpServer(new HttpServerOptions() + .setSsl(true) + .setKeyCertOptions(certificates.getDefault().orElseThrow().getKeyStoreOptions())) + .requestHandler(rc -> rc.response().end("Hello")).listen(8081).toCompletionStage().toCompletableFuture().join(); + + assertThatThrownBy(() -> client.get(8081, "localhost", "/") + .send().toCompletionStage().toCompletableFuture().join()).hasCauseInstanceOf(SSLHandshakeException.class); + } +} diff --git a/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredTrustStoreWithMTLSAndServerRejectionTest.java b/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredTrustStoreWithMTLSAndServerRejectionTest.java new file mode 100644 index 0000000000000..5cbd873342efc --- /dev/null +++ b/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredTrustStoreWithMTLSAndServerRejectionTest.java @@ -0,0 +1,87 @@ +package io.quarkus.tls; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import io.smallrye.certs.junit5.Certificates; +import io.vertx.core.Vertx; +import io.vertx.core.http.ClientAuth; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; + +@Certificates(baseDir = "target/certs", certificates = { + @Certificate(name = "expired-mtls", password = "password", formats = { Format.PKCS12 }, duration = -5, client = true) +}) +public class ExpiredTrustStoreWithMTLSAndServerRejectionTest { + + private static final String configuration = """ + # Server + quarkus.tls.key-store.p12.path=target/certs/expired-mtls-keystore.p12 + quarkus.tls.key-store.p12.password=password + quarkus.tls.trust-store.p12.path=target/certs/expired-mtls-server-truststore.p12 + quarkus.tls.trust-store.p12.password=password + quarkus.tls.trust-store.certificate-expiration-policy=reject + + # Client + quarkus.tls.warn.trust-store.p12.path=target/certs/expired-mtls-client-truststore.p12 + quarkus.tls.warn.trust-store.p12.password=password + quarkus.tls.warn.trust-store.certificate-expiration-policy=ignore + quarkus.tls.warn.key-store.p12.path=target/certs/expired-mtls-client-keystore.p12 + quarkus.tls.warn.key-store.p12.password=password + """; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest().setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .add(new StringAsset(configuration), "application.properties")); + + @Inject + TlsConfigurationRegistry certificates; + + @Inject + Vertx vertx; + + private HttpServer server; + + @AfterEach + void cleanup() { + if (server != null) { + server.close().toCompletionStage().toCompletableFuture().join(); + } + } + + @Test + void testServerRejection() throws InterruptedException { + TlsConfiguration cf = certificates.get("warn").orElseThrow(); + assertThat(cf.getTrustStoreOptions()).isNotNull(); + + WebClient client = WebClient.create(vertx, new WebClientOptions() + .setSsl(true) + .setKeyCertOptions(cf.getKeyStoreOptions()) + .setTrustOptions(cf.getTrustStoreOptions())); + + server = vertx.createHttpServer(new HttpServerOptions() + .setSsl(true) + .setClientAuth(ClientAuth.REQUIRED) + .setTrustOptions(certificates.getDefault().orElseThrow().getTrustStoreOptions()) + .setKeyCertOptions(certificates.getDefault().orElseThrow().getKeyStoreOptions())) + .requestHandler(rc -> rc.response().end("Hello")).listen(8081).toCompletionStage().toCompletableFuture().join(); + + assertThatThrownBy(() -> client.get(8081, "localhost", "/").send().toCompletionStage().toCompletableFuture().join()) + .hasMessageContaining("SSLHandshakeException"); + } +} diff --git a/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredTrustStoreWithMTLSTest.java b/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredTrustStoreWithMTLSTest.java new file mode 100644 index 0000000000000..a56fd7af49093 --- /dev/null +++ b/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/ExpiredTrustStoreWithMTLSTest.java @@ -0,0 +1,124 @@ +package io.quarkus.tls; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.CountDownLatch; + +import javax.net.ssl.SSLHandshakeException; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import io.smallrye.certs.junit5.Certificates; +import io.vertx.core.Vertx; +import io.vertx.core.http.ClientAuth; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; + +@Certificates(baseDir = "target/certs", certificates = { + @Certificate(name = "expired-mtls", password = "password", formats = { Format.PKCS12 }, duration = -5, client = true) +}) +public class ExpiredTrustStoreWithMTLSTest { + + private static final String configuration = """ + # Server + quarkus.tls.key-store.p12.path=target/certs/expired-mtls-keystore.p12 + quarkus.tls.key-store.p12.password=password + quarkus.tls.trust-store.p12.path=target/certs/expired-mtls-server-truststore.p12 + quarkus.tls.trust-store.p12.password=password + # The server will ignore the expired client certificates + + # Clients + quarkus.tls.warn.trust-store.p12.path=target/certs/expired-mtls-client-truststore.p12 + quarkus.tls.warn.trust-store.p12.password=password + quarkus.tls.warn.trust-store.certificate-expiration-policy=warn + quarkus.tls.warn.key-store.p12.path=target/certs/expired-mtls-client-keystore.p12 + quarkus.tls.warn.key-store.p12.password=password + + quarkus.tls.reject.trust-store.p12.path=target/certs/expired-mtls-client-truststore.p12 + quarkus.tls.reject.trust-store.p12.password=password + quarkus.tls.reject.trust-store.certificate-expiration-policy=reject + quarkus.tls.reject.key-store.p12.path=target/certs/expired-mtls-client-keystore.p12 + quarkus.tls.reject.key-store.p12.password=password + """; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest().setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .add(new StringAsset(configuration), "application.properties")); + + @Inject + TlsConfigurationRegistry certificates; + + @Inject + Vertx vertx; + + private HttpServer server; + + @AfterEach + void cleanup() { + if (server != null) { + server.close().toCompletionStage().toCompletableFuture().join(); + } + } + + @Test + void testWarn() throws InterruptedException { + TlsConfiguration cf = certificates.get("warn").orElseThrow(); + assertThat(cf.getTrustStoreOptions()).isNotNull(); + + WebClient client = WebClient.create(vertx, new WebClientOptions() + .setSsl(true) + .setKeyCertOptions(cf.getKeyStoreOptions()) + .setTrustOptions(cf.getTrustStoreOptions())); + + server = vertx.createHttpServer(new HttpServerOptions() + .setSsl(true) + .setClientAuth(ClientAuth.REQUIRED) + .setTrustOptions(certificates.getDefault().orElseThrow().getTrustStoreOptions()) + .setKeyCertOptions(certificates.getDefault().orElseThrow().getKeyStoreOptions())) + .requestHandler(rc -> rc.response().end("Hello")).listen(8081).toCompletionStage().toCompletableFuture().join(); + + CountDownLatch latch = new CountDownLatch(1); + client.get(8081, "localhost", "/").send(ar -> { + assertThat(ar.succeeded()).isTrue(); + assertThat(ar.result().bodyAsString()).isEqualTo("Hello"); + latch.countDown(); + }); + + assertThat(latch.await(10, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + } + + @Test + void testReject() { + TlsConfiguration cf = certificates.get("reject").orElseThrow(); + assertThat(cf.getTrustStoreOptions()).isNotNull(); + + WebClient client = WebClient.create(vertx, new WebClientOptions() + .setSsl(true) + .setKeyCertOptions(cf.getKeyStoreOptions()) + .setTrustOptions(cf.getTrustStoreOptions())); + + server = vertx.createHttpServer(new HttpServerOptions() + .setSsl(true) + .setClientAuth(ClientAuth.REQUIRED) + .setTrustOptions(certificates.getDefault().orElseThrow().getTrustStoreOptions()) + .setKeyCertOptions(certificates.getDefault().orElseThrow().getKeyStoreOptions())) + .requestHandler(rc -> rc.response().end("Hello")).listen(8081).toCompletionStage().toCompletableFuture().join(); + + assertThatThrownBy(() -> client.get(8081, "localhost", "/") + .send().toCompletionStage().toCompletableFuture().join()).hasCauseInstanceOf(SSLHandshakeException.class); + } +} diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/config/TrustStoreConfig.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/config/TrustStoreConfig.java index 7d7d75df0ffc0..319237618f92e 100644 --- a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/config/TrustStoreConfig.java +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/config/TrustStoreConfig.java @@ -3,6 +3,7 @@ import java.util.Optional; import io.quarkus.runtime.annotations.ConfigGroup; +import io.smallrye.config.WithDefault; @ConfigGroup public interface TrustStoreConfig { @@ -22,6 +23,32 @@ public interface TrustStoreConfig { */ Optional jks(); + /** + * Enforce certificate expiration. + * When enabled, the certificate expiration date is verified and the certificate (or any certificate in the chain) + * is rejected if it is expired. + */ + @WithDefault("WARN") + CertificateExpiryPolicy certificateExpirationPolicy(); + + /** + * The policy to apply when a certificate is expired. + */ + enum CertificateExpiryPolicy { + /** + * Ignore the expiration date. + */ + IGNORE, + /** + * Log a warning when the certificate is expired. + */ + WARN, + /** + * Reject the certificate if it is expired. + */ + REJECT + } + /** * The credential provider configuration for the trust store. * A credential provider offers a way to retrieve the trust store password. diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/ExpiryTrustOptions.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/ExpiryTrustOptions.java new file mode 100644 index 0000000000000..ea57db4c22f33 --- /dev/null +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/ExpiryTrustOptions.java @@ -0,0 +1,151 @@ +package io.quarkus.tls.runtime.keystores; + +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateExpiredException; +import java.security.cert.CertificateNotYetValidException; +import java.security.cert.X509Certificate; +import java.util.function.Function; + +import javax.net.ssl.ManagerFactoryParameters; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.TrustManagerFactorySpi; +import javax.net.ssl.X509TrustManager; + +import org.jboss.logging.Logger; + +import io.quarkus.tls.runtime.config.TrustStoreConfig; +import io.smallrye.mutiny.unchecked.Unchecked; +import io.smallrye.mutiny.unchecked.UncheckedFunction; +import io.vertx.core.Vertx; +import io.vertx.core.net.TrustOptions; + +/** + * A trust options that verify for the certificate expiration date and reject the certificate if it is expired. + */ +public class ExpiryTrustOptions implements TrustOptions { + + private final TrustOptions delegate; + private final TrustStoreConfig.CertificateExpiryPolicy policy; + + private static final Logger LOGGER = Logger.getLogger(ExpiryTrustOptions.class); + + public ExpiryTrustOptions(TrustOptions delegate, TrustStoreConfig.CertificateExpiryPolicy certificateExpiryPolicy) { + this.delegate = delegate; + this.policy = certificateExpiryPolicy; + } + + public TrustOptions unwrap() { + return delegate; + } + + @Override + public TrustOptions copy() { + return this; + } + + @Override + public TrustManagerFactory getTrustManagerFactory(Vertx vertx) throws Exception { + var tmf = delegate.getTrustManagerFactory(vertx); + return new TrustManagerFactory(new TrustManagerFactorySpi() { + @Override + protected void engineInit(KeyStore ks) throws KeyStoreException { + tmf.init(ks); + } + + @Override + protected void engineInit(ManagerFactoryParameters spec) throws InvalidAlgorithmParameterException { + tmf.init(spec); + } + + @Override + protected TrustManager[] engineGetTrustManagers() { + var managers = tmf.getTrustManagers(); + return getWrappedTrustManagers(managers); + } + }, tmf.getProvider(), tmf.getAlgorithm()) { + // Empty - we use this pattern to have access to the protected constructor + }; + } + + @Override + public Function trustManagerMapper(Vertx vertx) { + return Unchecked.function(new UncheckedFunction() { + @Override + public TrustManager[] apply(String s) throws Exception { + TrustManager[] tms = delegate.trustManagerMapper(vertx).apply(s); + return ExpiryTrustOptions.this.getWrappedTrustManagers(tms); + } + }); + } + + private TrustManager[] getWrappedTrustManagers(TrustManager[] tms) { + var wrapped = new TrustManager[tms.length]; + for (int i = 0; i < tms.length; i++) { + var manager = tms[i]; + if (!(manager instanceof X509TrustManager)) { + wrapped[i] = manager; + } else { + wrapped[i] = new ExpiryAwareX509TrustManager((X509TrustManager) manager); + } + } + return wrapped; + } + + private class ExpiryAwareX509TrustManager implements X509TrustManager { + + final X509TrustManager tm; + + private ExpiryAwareX509TrustManager(X509TrustManager tm) { + this.tm = tm; + } + + @Override + public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) + throws CertificateException { + verifyExpiration(chain); + tm.checkClientTrusted(chain, authType); + } + + private void verifyExpiration(X509Certificate[] chain) + throws CertificateExpiredException, CertificateNotYetValidException { + // Verify if there is any expired certificate in the chain - if so, throw an exception + for (X509Certificate cert : chain) { + try { + cert.checkValidity(); + } catch (CertificateExpiredException e) { + // Ignore has been handled before, so, no need to check for this value. + if (policy == TrustStoreConfig.CertificateExpiryPolicy.REJECT) { + LOGGER.error("A certificate has expired - rejecting", e); + throw e; + } else { // WARN + LOGGER.warn("A certificate has expired", e); + } + } catch (CertificateNotYetValidException e) { + // Ignore has been handled before, so, no need to check for this value. + if (policy == TrustStoreConfig.CertificateExpiryPolicy.REJECT) { + LOGGER.error("A certificate is not yet valid - rejecting", e); + throw e; + } else { // WARN + LOGGER.warn("A certificate is not yet valid", e); + } + } + } + } + + @Override + public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) + throws CertificateException { + verifyExpiration(chain); + tm.checkServerTrusted(chain, authType); + } + + @Override + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return tm.getAcceptedIssuers(); + } + } +} diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/JKSKeyStores.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/JKSKeyStores.java index 347127541b1f9..2fc073b2e0eaa 100644 --- a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/JKSKeyStores.java +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/JKSKeyStores.java @@ -43,7 +43,13 @@ public static TrustStoreAndTrustOptions verifyJKSTrustStoreStore(TrustStoreConfi JksOptions options = toOptions(jksConfig, config.credentialsProvider(), name); KeyStore ks = loadKeyStore(vertx, name, options, "trust"); verifyTrustStoreAlias(options, name, ks); - return new TrustStoreAndTrustOptions(ks, options); + if (config.certificateExpirationPolicy() == TrustStoreConfig.CertificateExpiryPolicy.IGNORE) { + return new TrustStoreAndTrustOptions(ks, options); + } else { + var wrapped = new ExpiryTrustOptions(options, config.certificateExpirationPolicy()); + return new TrustStoreAndTrustOptions(ks, wrapped); + } + } private static JksOptions toOptions(JKSKeyStoreConfig config, diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/P12KeyStores.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/P12KeyStores.java index 70f875be2568d..3d205261c836d 100644 --- a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/P12KeyStores.java +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/P12KeyStores.java @@ -43,7 +43,12 @@ public static TrustStoreAndTrustOptions verifyP12TrustStoreStore(TrustStoreConfi PfxOptions options = toOptions(p12Config, config.credentialsProvider(), name); KeyStore ks = loadKeyStore(vertx, name, options, "trust"); verifyTrustStoreAlias(p12Config.alias(), name, ks); - return new TrustStoreAndTrustOptions(ks, options); + if (config.certificateExpirationPolicy() == TrustStoreConfig.CertificateExpiryPolicy.IGNORE) { + return new TrustStoreAndTrustOptions(ks, options); + } else { + var wrapped = new ExpiryTrustOptions(options, config.certificateExpirationPolicy()); + return new TrustStoreAndTrustOptions(ks, wrapped); + } } private static PfxOptions toOptions(P12KeyStoreConfig config, KeyStoreCredentialProviderConfig pc, String name) { diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/PemKeyStores.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/PemKeyStores.java index 47a3e104e357c..e0a21c360b6df 100644 --- a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/PemKeyStores.java +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/keystores/PemKeyStores.java @@ -43,8 +43,13 @@ public static TrustStoreAndTrustOptions verifyPEMTrustStoreStore(TrustStoreConfi } try { var options = config.toOptions(); - KeyStore keyStore = options.loadKeyStore(vertx); - return new TrustStoreAndTrustOptions(keyStore, options); + KeyStore ks = options.loadKeyStore(vertx); + if (tsc.certificateExpirationPolicy() == TrustStoreConfig.CertificateExpiryPolicy.IGNORE) { + return new TrustStoreAndTrustOptions(ks, options); + } else { + var wrapped = new ExpiryTrustOptions(options, tsc.certificateExpirationPolicy()); + return new TrustStoreAndTrustOptions(ks, wrapped); + } } catch (UncheckedIOException e) { throw new IllegalStateException("Invalid PEM trusted certificates configuration for certificate '" + name + "' - cannot read the PEM certificate files", e); diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/menu/DependenciesProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/menu/DependenciesProcessor.java index 5708cb57c99e5..43d11dc5fe48c 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/menu/DependenciesProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/menu/DependenciesProcessor.java @@ -84,7 +84,7 @@ void createBuildTimeActions(BuildProducer buildTimeAct private boolean isEnabled() { var value = ConfigProvider.getConfig().getConfigValue("quarkus.bootstrap.incubating-model-resolver"); // if it's not false and if it's false it doesn't come from the default value - return value == null || !"false".equals(value.getValue()) || "default values".equals(value.getSourceName()); + return value == null || !"false".equals(value.getValue()) || "DefaultValuesConfigSource".equals(value.getSourceName()); } private void buildTree(ApplicationModel model, Root root, Optional> allGavs, Optional toTarget) { diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/InclusiveAuthValidationTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/InclusiveAuthValidationTest.java index 83285ca98d6ef..186b7a4b6dfc3 100644 --- a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/InclusiveAuthValidationTest.java +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/InclusiveAuthValidationTest.java @@ -89,7 +89,7 @@ public Set> getCredentialTypes() { @Override public int getPriority() { - return MtlsAuthenticationMechanism.PRIORITY + 1; + return MtlsAuthenticationMechanism.INCLUSIVE_AUTHENTICATION_PRIORITY + 1; } } } diff --git a/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-continuous-testing.js b/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-continuous-testing.js index 70451790bf5ca..72e3a7ab694b4 100644 --- a/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-continuous-testing.js +++ b/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-continuous-testing.js @@ -345,7 +345,6 @@ export class QwcContinuousTesting extends QwcHotReloadElement { @click="${!this._testsEnabled ? this._start : this._stop}" ?disabled=${this._busy}> - ${!this._testsEnabled ? 'Start' : 'Stop'} `; } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java index 08107d606038f..d251a34e732b7 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java @@ -43,6 +43,8 @@ public class AuthConfig { * authentication, for example, OIDC bearer token authentication, must succeed. * In such cases, `SecurityIdentity` created by the first mechanism, mTLS, can be injected, identities created * by other mechanisms will be available on `SecurityIdentity`. + * The mTLS mechanism is always the first mechanism, because its priority is elevated when inclusive authentication + * is enabled. * The identities can be retrieved using utility method as in the example below: * *

    diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/options/HttpServerOptionsUtils.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/options/HttpServerOptionsUtils.java
    index f270cc64b2bd6..dc441ca8d25c4 100644
    --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/options/HttpServerOptionsUtils.java
    +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/options/HttpServerOptionsUtils.java
    @@ -39,12 +39,15 @@
     import io.vertx.core.http.HttpVersion;
     import io.vertx.core.net.JdkSSLEngineOptions;
     import io.vertx.core.net.KeyCertOptions;
    +import io.vertx.core.net.TCPSSLOptions;
     import io.vertx.core.net.TrafficShapingOptions;
     import io.vertx.core.net.TrustOptions;
     
     @SuppressWarnings("OptionalIsPresent")
     public class HttpServerOptionsUtils {
     
    +    private static final boolean JDK_SSL_BUFFER_POOLING = Boolean.getBoolean("quarkus.http.server.ssl.jdk.bufferPooling");
    +
         /**
          * When the http port is set to 0, replace it by this value to let Vert.x choose a random port
          */
    @@ -172,6 +175,7 @@ private static void applySslConfigToHttpServerOptions(ServerSslConfig httpConfig
             serverOptions.setEnabledSecureTransportProtocols(sslConfig.protocols);
             serverOptions.setSsl(true);
             serverOptions.setSni(sslConfig.sni);
    +        setJdkHeapBufferPooling(serverOptions);
         }
     
         /**
    @@ -214,6 +218,7 @@ public static HttpServerOptions createSslOptionsForManagementInterface(Managemen
     
         public static void applyTlsConfigurationToHttpServerOptions(TlsConfiguration bucket, HttpServerOptions serverOptions) {
             serverOptions.setSsl(true);
    +        setJdkHeapBufferPooling(serverOptions);
     
             KeyCertOptions keyStoreOptions = bucket.getKeyStoreOptions();
             TrustOptions trustStoreOptions = bucket.getTrustStoreOptions();
    @@ -240,6 +245,20 @@ public static void applyTlsConfigurationToHttpServerOptions(TlsConfiguration buc
             serverOptions.setEnabledSecureTransportProtocols(other.getEnabledSecureTransportProtocols());
         }
     
    +    private static void setJdkHeapBufferPooling(TCPSSLOptions tcpSslOptions) {
    +        if (!JDK_SSL_BUFFER_POOLING) {
    +            return;
    +        }
    +        var engineOption = tcpSslOptions.getSslEngineOptions();
    +        if (engineOption == null) {
    +            var jdkEngineOptions = new JdkSSLEngineOptions();
    +            jdkEngineOptions.setPooledHeapBuffers(true);
    +            tcpSslOptions.setSslEngineOptions(jdkEngineOptions);
    +        } else if (engineOption instanceof JdkSSLEngineOptions jdkEngineOptions) {
    +            jdkEngineOptions.setPooledHeapBuffers(true);
    +        }
    +    }
    +
         public static Optional getCredential(Optional password, Map credentials,
                 Optional passwordKey) {
             if (password.isPresent()) {
    diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java
    index 0dfbc8f392975..da171acff63df 100644
    --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java
    +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java
    @@ -160,7 +160,7 @@ public int compare(HttpAuthenticationMechanism mech1, HttpAuthenticationMechanis
                                         the highest priority. Please lower priority of the '%s' authentication mechanism under '%s'.
                                         """.formatted(MtlsAuthenticationMechanism.class.getName(),
                                         topMechanism.getClass().getName(),
    -                                    MtlsAuthenticationMechanism.PRIORITY));
    +                                    MtlsAuthenticationMechanism.INCLUSIVE_AUTHENTICATION_PRIORITY));
                     }
                 }
             }
    diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java
    index 3ec1ae277341f..1f1faee90d42d 100644
    --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java
    +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java
    @@ -33,6 +33,7 @@
     import io.quarkus.runtime.annotations.Recorder;
     import io.quarkus.runtime.configuration.ConfigurationException;
     import io.quarkus.security.AuthenticationCompletionException;
    +import io.quarkus.security.AuthenticationException;
     import io.quarkus.security.AuthenticationFailedException;
     import io.quarkus.security.AuthenticationRedirectException;
     import io.quarkus.security.identity.SecurityIdentity;
    @@ -171,6 +172,12 @@ public Map get() {
     
         public static abstract class DefaultAuthFailureHandler implements BiConsumer {
     
    +        /**
    +         * A {@link RoutingContext#get(String)} key added for exceptions raised during authentication that are not
    +         * the {@link io.quarkus.security.AuthenticationException}.
    +         */
    +        private static final String OTHER_AUTHENTICATION_FAILURE = "io.quarkus.vertx.http.runtime.security.other-auth-failure";
    +
             protected DefaultAuthFailureHandler() {
             }
     
    @@ -206,6 +213,7 @@ public void accept(Throwable throwable) {
                     event.response().headers().set("Pragma", "no-cache");
                     proceed(throwable);
                 } else {
    +                event.put(OTHER_AUTHENTICATION_FAILURE, Boolean.TRUE);
                     event.fail(throwable);
                 }
             }
    @@ -227,6 +235,20 @@ public static Throwable extractRootCause(Throwable throwable) {
                 }
                 return throwable;
             }
    +
    +        public static void markIfOtherAuthenticationFailure(RoutingContext event, Throwable throwable) {
    +            if (!(throwable instanceof AuthenticationException)) {
    +                event.put(OTHER_AUTHENTICATION_FAILURE, Boolean.TRUE);
    +            }
    +        }
    +
    +        public static void removeMarkAsOtherAuthenticationFailure(RoutingContext event) {
    +            event.remove(OTHER_AUTHENTICATION_FAILURE);
    +        }
    +
    +        public static boolean isOtherAuthenticationFailure(RoutingContext event) {
    +            return Boolean.TRUE.equals(event.get(OTHER_AUTHENTICATION_FAILURE));
    +        }
         }
     
         public static final class AuthenticationHandler implements Handler {
    diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MtlsAuthenticationMechanism.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MtlsAuthenticationMechanism.java
    index fa7d77c449dec..e6316f0e1bc0e 100644
    --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MtlsAuthenticationMechanism.java
    +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MtlsAuthenticationMechanism.java
    @@ -17,6 +17,8 @@
      */
     package io.quarkus.vertx.http.runtime.security;
     
    +import static io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism.DEFAULT_PRIORITY;
    +
     import java.security.cert.Certificate;
     import java.security.cert.X509Certificate;
     import java.util.Collections;
    @@ -25,6 +27,8 @@
     
     import javax.net.ssl.SSLPeerUnverifiedException;
     
    +import org.eclipse.microprofile.config.inject.ConfigProperty;
    +
     import io.netty.handler.codec.http.HttpResponseStatus;
     import io.quarkus.security.credential.CertificateCredential;
     import io.quarkus.security.identity.IdentityProviderManager;
    @@ -39,10 +43,15 @@
      * The authentication handler responsible for mTLS client authentication
      */
     public class MtlsAuthenticationMechanism implements HttpAuthenticationMechanism {
    -    public static final int PRIORITY = 3000;
    +    public static final int INCLUSIVE_AUTHENTICATION_PRIORITY = 3000;
         private static final String ROLES_MAPPER_ATTRIBUTE = "roles_mapper";
    +    private final boolean inclusiveAuthentication;
         private Function> certificateToRoles = null;
     
    +    MtlsAuthenticationMechanism(@ConfigProperty(name = "quarkus.http.auth.inclusive") boolean inclusiveAuthentication) {
    +        this.inclusiveAuthentication = inclusiveAuthentication;
    +    }
    +
         @Override
         public Uni authenticate(RoutingContext context,
                 IdentityProviderManager identityProviderManager) {
    @@ -86,7 +95,7 @@ public Uni getCredentialTransport(RoutingContext contex
     
         @Override
         public int getPriority() {
    -        return PRIORITY;
    +        return inclusiveAuthentication ? INCLUSIVE_AUTHENTICATION_PRIORITY : DEFAULT_PRIORITY;
         }
     
         void setCertificateToRolesMapper(Function> certificateToRoles) {
    diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/VertxEventBusConsumerRecorder.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/VertxEventBusConsumerRecorder.java
    index 145e34e844bd8..469458e5117f2 100644
    --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/VertxEventBusConsumerRecorder.java
    +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/VertxEventBusConsumerRecorder.java
    @@ -19,7 +19,7 @@
     import java.util.function.Supplier;
     
     import org.eclipse.microprofile.config.Config;
    -import org.eclipse.microprofile.config.spi.ConfigProviderResolver;
    +import org.eclipse.microprofile.config.ConfigProvider;
     import org.jboss.logging.Logger;
     
     import io.quarkus.arc.CurrentContextFactory;
    @@ -310,9 +310,7 @@ private static String lookUpPropertyValue(String propertyValue) {
          * Adapted from {@link io.smallrye.config.ExpressionConfigSourceInterceptor}
          */
         private static String resolvePropertyExpression(String expr) {
    -        // Force the runtime CL in order to make the DEV UI page work
    -        final ClassLoader cl = VertxEventBusConsumerRecorder.class.getClassLoader();
    -        final Config config = ConfigProviderResolver.instance().getConfig(cl);
    +        final Config config = ConfigProvider.getConfig();
             final Expression expression = Expression.compile(expr, LENIENT_SYNTAX, NO_TRIM);
             final String expanded = expression.evaluate(new BiConsumer, StringBuilder>() {
                 @Override
    diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/QuarkusJacksonJsonCodec.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/QuarkusJacksonJsonCodec.java
    index 1e02b5e8fef81..fa3b646350729 100644
    --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/QuarkusJacksonJsonCodec.java
    +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/QuarkusJacksonJsonCodec.java
    @@ -28,7 +28,7 @@
      * The difference is that this class obtains the ObjectMapper from Arc in order to inherit the
      * user-customized ObjectMapper.
      */
    -class QuarkusJacksonJsonCodec implements JsonCodec {
    +public class QuarkusJacksonJsonCodec implements JsonCodec {
     
         private static volatile ObjectMapper mapper;
         // we don't want to create this unless it's absolutely necessary (and it rarely is)
    @@ -43,7 +43,7 @@ public static void reset() {
             prettyMapper = null;
         }
     
    -    private static ObjectMapper mapper() {
    +    public static ObjectMapper mapper() {
             if (mapper == null) {
                 synchronized (QuarkusJacksonJsonCodec.class) {
                     if (mapper == null) {
    diff --git a/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java b/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java
    index 6b33ff21fd719..9c8c02a2deadc 100644
    --- a/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java
    +++ b/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java
    @@ -45,9 +45,6 @@
     import io.quarkus.arc.deployment.BeanArchiveIndexBuildItem;
     import io.quarkus.arc.deployment.BeanDefiningAnnotationBuildItem;
     import io.quarkus.arc.deployment.BeanDiscoveryFinishedBuildItem;
    -import io.quarkus.arc.deployment.ContextRegistrationPhaseBuildItem;
    -import io.quarkus.arc.deployment.ContextRegistrationPhaseBuildItem.ContextConfiguratorBuildItem;
    -import io.quarkus.arc.deployment.CustomScopeBuildItem;
     import io.quarkus.arc.deployment.InvokerFactoryBuildItem;
     import io.quarkus.arc.deployment.SyntheticBeanBuildItem;
     import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem;
    @@ -126,7 +123,6 @@
     import io.quarkus.websockets.next.runtime.WebSocketEndpointBase;
     import io.quarkus.websockets.next.runtime.WebSocketHttpServerOptionsCustomizer;
     import io.quarkus.websockets.next.runtime.WebSocketServerRecorder;
    -import io.quarkus.websockets.next.runtime.WebSocketSessionContext;
     import io.quarkus.websockets.next.runtime.kotlin.ApplicationCoroutineScope;
     import io.quarkus.websockets.next.runtime.kotlin.CoroutineInvoker;
     import io.quarkus.websockets.next.runtime.telemetry.ErrorInterceptor;
    @@ -229,19 +225,6 @@ void produceCoroutineScope(BuildProducer additionalBean
                     .build());
         }
     
    -    @BuildStep
    -    ContextConfiguratorBuildItem registerSessionContext(ContextRegistrationPhaseBuildItem phase) {
    -        return new ContextConfiguratorBuildItem(phase.getContext()
    -                .configure(SessionScoped.class)
    -                .normal()
    -                .contextClass(WebSocketSessionContext.class));
    -    }
    -
    -    @BuildStep
    -    CustomScopeBuildItem registerSessionScope() {
    -        return new CustomScopeBuildItem(DotName.createSimple(SessionScoped.class));
    -    }
    -
         @BuildStep
         void builtinCallbackArguments(BuildProducer providers) {
             providers.produce(new CallbackArgumentBuildItem(new MessageCallbackArgument()));
    @@ -1613,12 +1596,15 @@ private static Callback findCallback(Target target, IndexView index, BeanInfo be
         private static ExecutionModel executionModel(MethodInfo method, TransformedAnnotationsBuildItem transformedAnnotations) {
             if (KotlinUtils.isKotlinSuspendMethod(method)
                     && (transformedAnnotations.hasAnnotation(method, WebSocketDotNames.RUN_ON_VIRTUAL_THREAD)
    +                        || transformedAnnotations.hasAnnotation(method.declaringClass(),
    +                                WebSocketDotNames.RUN_ON_VIRTUAL_THREAD)
                             || transformedAnnotations.hasAnnotation(method, WebSocketDotNames.BLOCKING)
                             || transformedAnnotations.hasAnnotation(method, WebSocketDotNames.NON_BLOCKING))) {
                 throw new WebSocketException("Kotlin `suspend` functions in WebSockets Next endpoints may not be "
                         + "annotated @Blocking, @NonBlocking or @RunOnVirtualThread: " + method);
             }
    -        if (transformedAnnotations.hasAnnotation(method, WebSocketDotNames.RUN_ON_VIRTUAL_THREAD)) {
    +        if (transformedAnnotations.hasAnnotation(method, WebSocketDotNames.RUN_ON_VIRTUAL_THREAD)
    +                || transformedAnnotations.hasAnnotation(method.declaringClass(), WebSocketDotNames.RUN_ON_VIRTUAL_THREAD)) {
                 return ExecutionModel.VIRTUAL_THREAD;
             } else if (transformedAnnotations.hasAnnotation(method, WebSocketDotNames.BLOCKING)) {
                 return ExecutionModel.WORKER_THREAD;
    diff --git a/extensions/websockets-next/deployment/src/test/java21/io/quarkus/websockets/next/test/virtualthreads/RunOnVirtualThreadTest.java b/extensions/websockets-next/deployment/src/test/java21/io/quarkus/websockets/next/test/virtualthreads/RunOnVirtualThreadTest.java
    index 0c767e18834cd..80676788b7dbf 100644
    --- a/extensions/websockets-next/deployment/src/test/java21/io/quarkus/websockets/next/test/virtualthreads/RunOnVirtualThreadTest.java
    +++ b/extensions/websockets-next/deployment/src/test/java21/io/quarkus/websockets/next/test/virtualthreads/RunOnVirtualThreadTest.java
    @@ -13,6 +13,7 @@
     import io.quarkus.test.common.http.TestHTTPResource;
     import io.quarkus.test.vertx.VirtualThreadsAssertions;
     import io.quarkus.websockets.next.OnError;
    +import io.quarkus.websockets.next.OnOpen;
     import io.quarkus.websockets.next.OnTextMessage;
     import io.quarkus.websockets.next.WebSocket;
     import io.quarkus.websockets.next.test.utils.WSClient;
    @@ -38,6 +39,9 @@ public class RunOnVirtualThreadTest {
         @TestHTTPResource("end")
         URI endUri;
     
    +    @TestHTTPResource("virt-on-class")
    +    URI onClassUri;
    +
         @Test
         void testVirtualThreads() {
             try (WSClient client = new WSClient(vertx).connect(endUri)) {
    @@ -52,6 +56,22 @@ void testVirtualThreads() {
             }
         }
     
    +    @Test
    +    void testVirtualThreadsOnClass() {
    +        try (WSClient client = new WSClient(vertx).connect(onClassUri)) {
    +            client.sendAndAwait("foo");
    +            client.sendAndAwait("bar");
    +            client.waitForMessages(3);
    +            String open = client.getMessages().get(0).toString();
    +            String message1 = client.getMessages().get(1).toString();
    +            String message2 = client.getMessages().get(2).toString();
    +            assertNotEquals(open, message1, message2);
    +            assertTrue(open.startsWith("wsnext-virtual-thread-"));
    +            assertTrue(message1.startsWith("wsnext-virtual-thread-"));
    +            assertTrue(message2.startsWith("wsnext-virtual-thread-"));
    +        }
    +    }
    +
         @WebSocket(path = "/end")
         public static class Endpoint {
     
    @@ -71,7 +91,27 @@ String error(Throwable t) {
             }
     
         }
    -    
    +
    +    @RunOnVirtualThread
    +    @WebSocket(path = "/virt-on-class")
    +    public static class EndpointVirtOnClass {
    +
    +        @Inject
    +        RequestScopedBean bean;
    +
    +        @OnOpen
    +        String open() {
    +            VirtualThreadsAssertions.assertEverything();
    +            return Thread.currentThread().getName();
    +        }
    +
    +        @OnTextMessage
    +        String text(String ignored) {
    +            VirtualThreadsAssertions.assertEverything();
    +            return Thread.currentThread().getName();
    +        }
    +    }
    +
         @RequestScoped
         public static class RequestScopedBean {
             
    diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/ContextSupport.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/ContextSupport.java
    index 2b60536dfc45b..26823e8b9b5c7 100644
    --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/ContextSupport.java
    +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/ContextSupport.java
    @@ -7,7 +7,6 @@
     import io.quarkus.arc.InjectableContext.ContextState;
     import io.quarkus.arc.ManagedContext;
     import io.quarkus.vertx.core.runtime.context.VertxContextSafetyToggle;
    -import io.quarkus.websockets.next.runtime.WebSocketSessionContext.SessionContextState;
     import io.smallrye.common.vertx.VertxContext;
     import io.vertx.core.Context;
     
    @@ -21,12 +20,12 @@ public class ContextSupport {
         static final String WEB_SOCKET_CONN_KEY = WebSocketConnectionBase.class.getName();
     
         private final WebSocketConnectionBase connection;
    -    private final SessionContextState sessionContextState;
    -    private final WebSocketSessionContext sessionContext;
    +    private final ContextState sessionContextState;
    +    private final ManagedContext sessionContext;
         private final ManagedContext requestContext;
     
    -    ContextSupport(WebSocketConnectionBase connection, SessionContextState sessionContextState,
    -            WebSocketSessionContext sessionContext,
    +    ContextSupport(WebSocketConnectionBase connection, ContextState sessionContextState,
    +            ManagedContext sessionContext,
                 ManagedContext requestContext) {
             this.connection = connection;
             this.sessionContext = sessionContext;
    diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/Endpoints.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/Endpoints.java
    index 349ccd7a75aff..64f43ee3a7377 100644
    --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/Endpoints.java
    +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/Endpoints.java
    @@ -4,20 +4,18 @@
     import java.util.Optional;
     import java.util.function.Consumer;
     
    -import jakarta.enterprise.context.SessionScoped;
    -
     import org.jboss.logging.Logger;
     
     import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus;
     import io.quarkus.arc.ArcContainer;
     import io.quarkus.arc.InjectableContext;
    +import io.quarkus.arc.ManagedContext;
     import io.quarkus.runtime.LaunchMode;
     import io.quarkus.security.AuthenticationFailedException;
     import io.quarkus.security.ForbiddenException;
     import io.quarkus.security.UnauthorizedException;
     import io.quarkus.websockets.next.CloseReason;
     import io.quarkus.websockets.next.WebSocketException;
    -import io.quarkus.websockets.next.runtime.WebSocketSessionContext.SessionContextState;
     import io.quarkus.websockets.next.runtime.config.UnhandledFailureStrategy;
     import io.quarkus.websockets.next.runtime.telemetry.ErrorInterceptor;
     import io.quarkus.websockets.next.runtime.telemetry.TelemetrySupport;
    @@ -43,11 +41,11 @@ static void initialize(Vertx vertx, ArcContainer container, Codecs codecs, WebSo
     
             // Initialize and capture the session context state that will be activated
             // during message processing
    -        WebSocketSessionContext sessionContext = null;
    -        SessionContextState sessionContextState = null;
    +        ManagedContext sessionContext = null;
    +        InjectableContext.ContextState sessionContextState = null;
             if (activateSessionContext) {
    -            sessionContext = sessionContext(container);
    -            sessionContextState = sessionContext.initializeContextState();
    +            sessionContext = container.sessionContext();
    +            sessionContextState = sessionContext.initializeState();
             }
             ContextSupport contextSupport = new ContextSupport(connection, sessionContextState,
                     sessionContext, activateRequestContext ? container.requestContext() : null);
    @@ -406,12 +404,4 @@ private static WebSocketEndpoint createEndpoint(String endpointClassName, Contex
             }
         }
     
    -    private static WebSocketSessionContext sessionContext(ArcContainer container) {
    -        for (InjectableContext injectableContext : container.getContexts(SessionScoped.class)) {
    -            if (WebSocketSessionContext.class.equals(injectableContext.getClass())) {
    -                return (WebSocketSessionContext) injectableContext;
    -            }
    -        }
    -        throw new WebSocketException("CDI session context not registered");
    -    }
     }
    diff --git a/extensions/websockets/client/deployment/pom.xml b/extensions/websockets/client/deployment/pom.xml
    index 165456e8d7476..9b61a1c37c51c 100644
    --- a/extensions/websockets/client/deployment/pom.xml
    +++ b/extensions/websockets/client/deployment/pom.xml
    @@ -52,9 +52,6 @@
                                         ${project.version}
                                     
                                 
    -                            
    -                                -AlegacyConfigRoot=true
    -                            
                             
                         
                     
    diff --git a/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebsocketClientProcessor.java b/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebsocketClientProcessor.java
    index 7d1d2544dca8d..98d8a1e56f3a4 100644
    --- a/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebsocketClientProcessor.java
    +++ b/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebsocketClientProcessor.java
    @@ -131,8 +131,8 @@ public ServerWebSocketContainerBuildItem deploy(final CombinedIndexBuildItem ind
                             .build());
     
             RuntimeValue deploymentInfo = recorder.createDeploymentInfo(annotated, endpoints, config,
    -                websocketConfig.maxFrameSize,
    -                websocketConfig.dispatchToWorker);
    +                websocketConfig.maxFrameSize(),
    +                websocketConfig.dispatchToWorker());
             infoBuildItemBuildProducer.produce(new WebSocketDeploymentInfoBuildItem(deploymentInfo));
             RuntimeValue serverContainer = recorder.createServerContainer(
                     beanContainerBuildItem.getValue(),
    diff --git a/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebsocketConfig.java b/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebsocketConfig.java
    index 51a5000d50698..b6ce071d63f82 100644
    --- a/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebsocketConfig.java
    +++ b/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebsocketConfig.java
    @@ -1,24 +1,26 @@
     package io.quarkus.websockets.client.deployment;
     
    -import io.quarkus.runtime.annotations.ConfigItem;
     import io.quarkus.runtime.annotations.ConfigPhase;
     import io.quarkus.runtime.annotations.ConfigRoot;
    +import io.smallrye.config.ConfigMapping;
    +import io.smallrye.config.WithDefault;
     
     @ConfigRoot(phase = ConfigPhase.BUILD_TIME)
    -public class WebsocketConfig {
    +@ConfigMapping(prefix = "quarkus.websocket")
    +public interface WebsocketConfig {
     
         /**
          * The maximum amount of data that can be sent in a single frame.
          *
          * Messages larger than this must be broken up into continuation frames.
          */
    -    @ConfigItem(defaultValue = "65536")
    -    public int maxFrameSize;
    +    @WithDefault("65536")
    +    int maxFrameSize();
     
         /**
          * If the websocket methods should be run in a worker thread. This allows them to run
          * blocking tasks, however it will not be as fast as running directly in the IO thread.
          */
    -    @ConfigItem(defaultValue = "false")
    -    public boolean dispatchToWorker;
    +    @WithDefault("false")
    +    boolean dispatchToWorker();
     }
    diff --git a/extensions/websockets/client/runtime/pom.xml b/extensions/websockets/client/runtime/pom.xml
    index 9286ecd0313fa..03b9e72318f5d 100644
    --- a/extensions/websockets/client/runtime/pom.xml
    +++ b/extensions/websockets/client/runtime/pom.xml
    @@ -71,9 +71,6 @@
                                         ${project.version}
                                     
                                 
    -                            
    -                                -AlegacyConfigRoot=true
    -                            
                             
                         
                     
    diff --git a/independent-projects/arc/pom.xml b/independent-projects/arc/pom.xml
    index 701e14d510271..58403e9f51699 100644
    --- a/independent-projects/arc/pom.xml
    +++ b/independent-projects/arc/pom.xml
    @@ -50,7 +50,7 @@
             2.7.0
             1.6.Final
             
    -        3.26.3
    +        3.27.0
             5.10.5
             2.0.21
             1.9.0
    diff --git a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanConfigurator.java b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanConfigurator.java
    index 156df45341604..80f7b6add09fd 100644
    --- a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanConfigurator.java
    +++ b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanConfigurator.java
    @@ -85,6 +85,14 @@ public void done() {
                             .build());
                 }
     
    +            // perform type discovery for registered types
    +            for (Type jandexType : registeredTypeClosures) {
    +                this.types.addAll(Types.getTypeClosureFromJandexType(jandexType, beanDeployment).unrestrictedTypes());
    +            }
    +
    +            // restrict resulting bean types if needed
    +            this.types.removeAll(typesToRemove);
    +
                 BeanInfo.Builder builder = new BeanInfo.Builder()
                         .implClazz(implClass)
                         .identifier(identifier)
    diff --git a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanConfiguratorBase.java b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanConfiguratorBase.java
    index 84644292fd52a..f2992031c8ba4 100644
    --- a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanConfiguratorBase.java
    +++ b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanConfiguratorBase.java
    @@ -45,6 +45,8 @@ public abstract class BeanConfiguratorBase types;
    +    protected final Set registeredTypeClosures;
    +    protected final Set typesToRemove;
         protected final Set qualifiers;
         protected ScopeInfo scope;
         protected Boolean alternative;
    @@ -66,6 +68,8 @@ public abstract class BeanConfiguratorBase();
    +        this.registeredTypeClosures = new HashSet<>();
    +        this.typesToRemove = new HashSet<>();
             this.qualifiers = new HashSet<>();
             this.stereotypes = new ArrayList<>();
             this.removable = true;
    @@ -83,6 +87,10 @@ public THIS read(BeanConfiguratorBase base) {
             identifier = base.identifier;
             types.clear();
             types.addAll(base.types);
    +        registeredTypeClosures.clear();
    +        registeredTypeClosures.addAll(base.registeredTypeClosures);
    +        typesToRemove.clear();
    +        typesToRemove.addAll(base.typesToRemove);
             qualifiers.clear();
             qualifiers.addAll(base.qualifiers);
             scope = base.scope;
    @@ -134,6 +142,74 @@ public THIS addType(Class type) {
             return addType(DotName.createSimple(type.getName()));
         }
     
    +    /**
    +     * Adds an unrestricted set of bean types for the given type as if it represented a bean class of a managed bean.
    +     *
    +     * @param typeName {@link DotName} representation of a class that should be scanned for types
    +     * @return self
    +     */
    +    public THIS addTypeClosure(DotName typeName) {
    +        return addTypeClosure(Type.create(typeName, Kind.CLASS));
    +    }
    +
    +    /**
    +     * Adds an unrestricted set of bean types for the given type as if it represented a bean class of a managed bean.
    +     *
    +     * @param type a class that should be scanned for types
    +     * @return self
    +     */
    +    public THIS addTypeClosure(Class type) {
    +        return addTypeClosure(Type.create(type));
    +    }
    +
    +    /**
    +     * Adds an unrestricted set of bean types for the given type as if it represented a bean class of a managed bean.
    +     *
    +     * @param type {@link Type} representation of a class that should be scanned for types
    +     * @return self
    +     */
    +    public THIS addTypeClosure(Type type) {
    +        this.registeredTypeClosures.add(type);
    +        return self();
    +    }
    +
    +    /**
    +     * Removes listed types from the resulting types of the synthetic bean.
    +     *
    +     * @param types types that should be removed from the resulting set of bean types
    +     * @return self
    +     */
    +    public THIS removeTypes(Class... types) {
    +        for (Class classType : types) {
    +            removeTypes(Type.create(classType));
    +        }
    +        return self();
    +    }
    +
    +    /**
    +     * Removes listed types from the resulting types of the synthetic bean.
    +     *
    +     * @param types types that should be removed from the resulting set of bean types
    +     * @return self
    +     */
    +    public THIS removeTypes(DotName... types) {
    +        for (DotName name : types) {
    +            removeTypes(Type.create(name, Kind.CLASS));
    +        }
    +        return self();
    +    }
    +
    +    /**
    +     * Removes listed types from the resulting types of the synthetic bean.
    +     *
    +     * @param types types that should be removed from the resulting set of bean types
    +     * @return self
    +     */
    +    public THIS removeTypes(Type... types) {
    +        Collections.addAll(this.typesToRemove, types);
    +        return self();
    +    }
    +
         public THIS addQualifier(Class annotationClass) {
             return addQualifier(DotName.createSimple(annotationClass.getName()));
         }
    diff --git a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanRegistrar.java b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanRegistrar.java
    index fb21183066102..c1307f808b520 100644
    --- a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanRegistrar.java
    +++ b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanRegistrar.java
    @@ -25,7 +25,7 @@ interface RegistrationContext extends BuildContext {
              * Configure a new synthetic bean. The bean is not added to the deployment unless the {@link BeanConfigurator#done()}
              * method is called.
              *
    -         * @param beanClass
    +         * @param beanClassName
              * @return a new synthetic bean configurator
              */
              BeanConfigurator configure(DotName beanClassName);
    diff --git a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BuiltinScope.java b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BuiltinScope.java
    index 29ade50172ec4..d543ac90be81f 100644
    --- a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BuiltinScope.java
    +++ b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BuiltinScope.java
    @@ -5,6 +5,7 @@
     import jakarta.enterprise.context.ApplicationScoped;
     import jakarta.enterprise.context.Dependent;
     import jakarta.enterprise.context.RequestScoped;
    +import jakarta.enterprise.context.SessionScoped;
     import jakarta.inject.Singleton;
     
     import org.jboss.jandex.AnnotationInstance;
    @@ -16,7 +17,8 @@ public enum BuiltinScope {
         DEPENDENT(Dependent.class, false),
         SINGLETON(Singleton.class, false),
         APPLICATION(ApplicationScoped.class, true),
    -    REQUEST(RequestScoped.class, true);
    +    REQUEST(RequestScoped.class, true),
    +    SESSION(SessionScoped.class, true);
     
         private ScopeInfo info;
     
    diff --git a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/InjectionPointModifier.java b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/InjectionPointModifier.java
    index bcac6d1bf086d..7b54da5941b6b 100644
    --- a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/InjectionPointModifier.java
    +++ b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/InjectionPointModifier.java
    @@ -3,7 +3,6 @@
     import java.util.HashSet;
     import java.util.List;
     import java.util.Set;
    -import java.util.stream.Collectors;
     
     import org.jboss.jandex.AnnotationInstance;
     import org.jboss.jandex.AnnotationTarget;
    @@ -42,14 +41,7 @@ public Set applyTransformers(Type type, AnnotationTarget tar
                     transformer.transform(transformationContext);
                 }
             }
    -        if (methodParameterTarget != null && AnnotationTarget.Kind.METHOD_PARAMETER.equals(methodParameterTarget.kind())) {
    -            // only return set of qualifiers related to the given method parameter
    -            return transformationContext.getQualifiers().stream().filter(
    -                    annotationInstance -> methodParameterTarget.equals(annotationInstance.target()))
    -                    .collect(Collectors.toSet());
    -        } else {
    -            return transformationContext.getQualifiers();
    -        }
    +        return transformationContext.getQualifiers();
         }
     
         // method variant used for field and resource field injection; a case where we don't need to deal with method. params
    diff --git a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/InterceptionProxyGenerator.java b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/InterceptionProxyGenerator.java
    index 49d790bb94d31..9ff06914aca7a 100644
    --- a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/InterceptionProxyGenerator.java
    +++ b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/InterceptionProxyGenerator.java
    @@ -24,7 +24,7 @@
     
     import io.quarkus.arc.InjectableReferenceProvider;
     import io.quarkus.arc.InterceptionProxy;
    -import io.quarkus.arc.Subclass;
    +import io.quarkus.arc.InterceptionProxySubclass;
     import io.quarkus.arc.impl.InterceptedMethodMetadata;
     import io.quarkus.arc.processor.ResourceOutput.Resource;
     import io.quarkus.gizmo.BytecodeCreator;
    @@ -156,8 +156,8 @@ private void createInterceptionSubclass(ClassOutput classOutput, InterceptionPro
     
             String superClass = isInterface ? Object.class.getName() : pseudoBeanClassName;
             String[] interfaces = isInterface
    -                ? new String[] { pseudoBeanClassName, Subclass.class.getName() }
    -                : new String[] { Subclass.class.getName() };
    +                ? new String[] { pseudoBeanClassName, InterceptionProxySubclass.class.getName() }
    +                : new String[] { InterceptionProxySubclass.class.getName() };
     
             try (ClassCreator clazz = ClassCreator.builder()
                     .classOutput(classOutput)
    @@ -352,6 +352,9 @@ private void createInterceptionSubclass(ClassOutput classOutput, InterceptionPro
     
                 ctor.writeInstanceField(constructedField.getFieldDescriptor(), ctor.getThis(), ctor.load(true));
                 ctor.returnVoid();
    +
    +            MethodCreator getDelegate = clazz.getMethodCreator("arc_delegate", Object.class);
    +            getDelegate.returnValue(getDelegate.readInstanceField(delegate.getFieldDescriptor(), getDelegate.getThis()));
             }
         }
     }
    diff --git a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/Types.java b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/Types.java
    index a2018013dc4c4..e085ef67e1516 100644
    --- a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/Types.java
    +++ b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/Types.java
    @@ -522,6 +522,38 @@ record TypeClosure(Set types, Set unrestrictedTypes) {
             }
         }
     
    +    static TypeClosure getTypeClosureFromJandexType(Type jandexType, BeanDeployment beanDeployment) {
    +        Set types;
    +        Set unrestrictedBeanTypes = new HashSet<>();
    +        if (jandexType.kind() == Kind.TYPE_VARIABLE) {
    +            throw new IllegalStateException("A type variable is not a legal bean type");
    +        }
    +        if (jandexType.kind() == Kind.PRIMITIVE || jandexType.kind() == Kind.ARRAY) {
    +            types = new HashSet<>();
    +            types.add(jandexType);
    +            types.add(OBJECT_TYPE);
    +            return new TypeClosure(types);
    +        } else {
    +            ClassInfo jandexTypeClassInfo = getClassByName(beanDeployment.getBeanArchiveIndex(), jandexType);
    +            if (jandexTypeClassInfo == null) {
    +                throw new IllegalArgumentException(
    +                        "Provided Jandex type not found in index: " + jandexType.name());
    +            }
    +            if (Kind.CLASS.equals(jandexType.kind())) {
    +                types = getTypeClosure(jandexTypeClassInfo, null, Collections.emptyMap(), beanDeployment, null,
    +                        unrestrictedBeanTypes);
    +            } else if (Kind.PARAMETERIZED_TYPE.equals(jandexType.kind())) {
    +                types = getTypeClosure(jandexTypeClassInfo, null,
    +                        buildResolvedMap(jandexType.asParameterizedType().arguments(), jandexTypeClassInfo.typeParameters(),
    +                                Collections.emptyMap(), beanDeployment.getBeanArchiveIndex()),
    +                        beanDeployment, null, unrestrictedBeanTypes);
    +            } else {
    +                throw new IllegalArgumentException("Unsupported return type");
    +            }
    +        }
    +        return new TypeClosure(types, unrestrictedBeanTypes);
    +    }
    +
         static Set getClassUnrestrictedTypeClosure(ClassInfo classInfo, BeanDeployment beanDeployment) {
             Set types;
             Set unrestrictedBeanTypes = new HashSet<>();
    diff --git a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/ArcContainer.java b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/ArcContainer.java
    index 1ed13a4a95040..c4f3a4a3ea654 100644
    --- a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/ArcContainer.java
    +++ b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/ArcContainer.java
    @@ -204,6 +204,13 @@ public interface ArcContainer {
          */
         ManagedContext requestContext();
     
    +    /**
    +     * This method never throws {@link ContextNotActiveException}.
    +     *
    +     * @return the built-in context for {@link jakarta.enterprise.context.SessionScoped}
    +     */
    +    ManagedContext sessionContext();
    +
         /**
          * NOTE: Not all methods are supported!
          *
    diff --git a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/ClientProxy.java b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/ClientProxy.java
    index 8af0bdf78951d..be10c658e31c4 100644
    --- a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/ClientProxy.java
    +++ b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/ClientProxy.java
    @@ -42,14 +42,14 @@ public interface ClientProxy {
          * This method should only be used with caution. If you unwrap a client proxy then certain key functionality will not work
          * as expected.
          *
    -     * @param 
    -     * @param obj
    +     * @param  the type of the object to unwrap
    +     * @param obj the object to unwrap
          * @return the contextual instance if the object represents a client proxy, the object otherwise
          */
         @SuppressWarnings("unchecked")
         static  T unwrap(T obj) {
    -        if (obj instanceof ClientProxy) {
    -            return (T) ((ClientProxy) obj).arc_contextualInstance();
    +        if (obj instanceof ClientProxy proxy) {
    +            return (T) proxy.arc_contextualInstance();
             }
             return obj;
         }
    diff --git a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/InterceptionProxySubclass.java b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/InterceptionProxySubclass.java
    new file mode 100644
    index 0000000000000..6b4afb3cf432f
    --- /dev/null
    +++ b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/InterceptionProxySubclass.java
    @@ -0,0 +1,33 @@
    +package io.quarkus.arc;
    +
    +/**
    + * Represents an interception proxy. Typically, interception is performed by creating a subclass
    + * of the original class and arranging bean instantiation such that the contextual instance
    + * is in fact an instance of the subclass, but that isn't always possible. In case of
    + * {@link InterceptionProxy}, interception is performed by a proxy that delegates to the actual
    + * contextual instance. Such proxy implements this interface.
    + */
    +public interface InterceptionProxySubclass extends Subclass {
    +    /**
    +     * @return the contextual instance
    +     */
    +    Object arc_delegate();
    +
    +    /**
    +     * Attempts to unwrap the object if it represents an interception proxy.
    +     * 

    + * This method should only be used with caution. If you unwrap an interception proxy, + * then certain key functionality will not work as expected. + * + * @param the type of the object to unwrap + * @param obj the object to unwrap + * @return the contextual instance if the object represents an interception proxy, the object otherwise + */ + @SuppressWarnings("unchecked") + static T unwrap(T obj) { + if (obj instanceof InterceptionProxySubclass proxy) { + return (T) proxy.arc_delegate(); + } + return obj; + } +} diff --git a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/ManagedContext.java b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/ManagedContext.java index 69e7d09cbc08c..2cfbf4913628f 100644 --- a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/ManagedContext.java +++ b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/ManagedContext.java @@ -50,4 +50,10 @@ default void terminate() { destroy(); deactivate(); } + + /** + * + * @return a new initialized context state + */ + ContextState initializeState(); } diff --git a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/ArcContainerImpl.java b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/ArcContainerImpl.java index c63710b5cbd86..d9843805156a6 100644 --- a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/ArcContainerImpl.java +++ b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/ArcContainerImpl.java @@ -36,6 +36,7 @@ import jakarta.enterprise.context.Initialized; import jakarta.enterprise.context.NormalScope; import jakarta.enterprise.context.RequestScoped; +import jakarta.enterprise.context.SessionScoped; import jakarta.enterprise.event.Event; import jakarta.enterprise.inject.AmbiguousResolutionException; import jakarta.enterprise.inject.Any; @@ -207,9 +208,14 @@ public List get() { notifierOrNull(Set.of(BeforeDestroyed.Literal.REQUEST, Any.Literal.INSTANCE)), notifierOrNull(Set.of(Destroyed.Literal.REQUEST, Any.Literal.INSTANCE)), requestContextInstances != null ? requestContextInstances : ComputingCacheContextInstances::new); + SessionContext sessionContext = new SessionContext(this.currentContextFactory.create(SessionScoped.class), + notifierOrNull(Set.of(Initialized.Literal.SESSION, Any.Literal.INSTANCE)), + notifierOrNull(Set.of(BeforeDestroyed.Literal.SESSION, Any.Literal.INSTANCE)), + notifierOrNull(Set.of(Destroyed.Literal.SESSION, Any.Literal.INSTANCE)), ComputingCacheContextInstances::new); Contexts.Builder contextsBuilder = new Contexts.Builder( requestContext, + sessionContext, applicationContext, new SingletonContext(), new DependentContext()); @@ -399,6 +405,11 @@ public ManagedContext requestContext() { return contexts.requestContext; } + @Override + public ManagedContext sessionContext() { + return contexts.sessionContext; + } + @Override public BeanManager beanManager() { return BeanManagerImpl.INSTANCE.get(); diff --git a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/Contexts.java b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/Contexts.java index 5858b982dcc87..b0ec74902fa0b 100644 --- a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/Contexts.java +++ b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/Contexts.java @@ -12,6 +12,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.Dependent; import jakarta.enterprise.context.RequestScoped; +import jakarta.enterprise.context.SessionScoped; import jakarta.inject.Singleton; import io.quarkus.arc.InjectableContext; @@ -26,6 +27,7 @@ class Contexts { // Built-in contexts final ManagedContext requestContext; + final ManagedContext sessionContext; final InjectableContext applicationContext; final InjectableContext singletonContext; final InjectableContext dependentContext; @@ -35,6 +37,7 @@ class Contexts { private final List singletonContextSingleton; private final List dependentContextSingleton; private final List requestContextSingleton; + private final List sessionContextSingleton; // Lazily computed list of contexts for a scope private final ClassValue> unoptimizedContexts; @@ -42,18 +45,22 @@ class Contexts { // Precomputed values final Set> scopes; - Contexts(ManagedContext requestContext, InjectableContext applicationContext, InjectableContext singletonContext, + Contexts(ManagedContext requestContext, ManagedContext sessionContext, InjectableContext applicationContext, + InjectableContext singletonContext, InjectableContext dependentContext, Map, List> contexts) { this.requestContext = requestContext; + this.sessionContext = sessionContext; this.applicationContext = applicationContext; this.singletonContext = singletonContext; this.dependentContext = dependentContext; - this.applicationContextSingleton = Collections.singletonList(applicationContext); - this.singletonContextSingleton = Collections.singletonList(singletonContext); - this.dependentContextSingleton = Collections.singletonList(dependentContext); + this.applicationContextSingleton = List.of(applicationContext); + this.singletonContextSingleton = List.of(singletonContext); + this.dependentContextSingleton = List.of(dependentContext); List requestContexts = contexts.get(RequestScoped.class); - this.requestContextSingleton = requestContexts != null ? requestContexts : Collections.singletonList(requestContext); + this.requestContextSingleton = requestContexts != null ? requestContexts : List.of(requestContext); + List sessionContexts = contexts.get(SessionScoped.class); + this.sessionContextSingleton = sessionContexts != null ? sessionContexts : List.of(sessionContext); if (!contexts.isEmpty()) { // At least one custom context is registered @@ -84,11 +91,13 @@ protected List computeValue(Class type) { all.add(Singleton.class); all.add(Dependent.class); all.add(RequestScoped.class); + all.add(SessionScoped.class); this.scopes = Set.copyOf(all); } else { // No custom context is registered this.unoptimizedContexts = null; - this.scopes = Set.of(ApplicationScoped.class, Singleton.class, Dependent.class, RequestScoped.class); + this.scopes = Set.of(ApplicationScoped.class, Singleton.class, Dependent.class, RequestScoped.class, + SessionScoped.class); } } @@ -125,6 +134,8 @@ List getContexts(Class scopeType) { return singletonContextSingleton; } else if (Dependent.class.equals(scopeType)) { return dependentContextSingleton; + } else if (SessionScoped.class.equals(scopeType)) { + return sessionContextSingleton; } return unoptimizedContexts != null ? unoptimizedContexts.get(scopeType) : Collections.emptyList(); } @@ -132,14 +143,16 @@ List getContexts(Class scopeType) { static class Builder { private final ManagedContext requestContext; + private final ManagedContext sessionContext; private final InjectableContext applicationContext; private final InjectableContext singletonContext; private final InjectableContext dependentContext; private final Map, List> contexts = new HashMap<>(); - public Builder(ManagedContext requestContext, InjectableContext applicationContext, + public Builder(ManagedContext requestContext, ManagedContext sessionContext, InjectableContext applicationContext, InjectableContext singletonContext, InjectableContext dependentContext) { this.requestContext = requestContext; + this.sessionContext = sessionContext; this.applicationContext = applicationContext; this.singletonContext = singletonContext; this.dependentContext = dependentContext; @@ -163,7 +176,8 @@ Contexts build() { // If a custom request context is registered then add the built-in context as well putContext(requestContext); } - return new Contexts(requestContext, applicationContext, singletonContext, dependentContext, contexts); + return new Contexts(requestContext, sessionContext, applicationContext, singletonContext, dependentContext, + contexts); } } diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketSessionContext.java b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/CurrentManagedContext.java similarity index 52% rename from extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketSessionContext.java rename to independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/CurrentManagedContext.java index 3d6c488289c41..266185912d5a1 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketSessionContext.java +++ b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/CurrentManagedContext.java @@ -1,61 +1,53 @@ -package io.quarkus.websockets.next.runtime; +package io.quarkus.arc.impl; -import java.lang.annotation.Annotation; import java.lang.invoke.MethodHandles; import java.lang.invoke.VarHandle; import java.util.Map; import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; -import jakarta.enterprise.context.BeforeDestroyed; import jakarta.enterprise.context.ContextNotActiveException; -import jakarta.enterprise.context.Destroyed; -import jakarta.enterprise.context.Initialized; -import jakarta.enterprise.context.SessionScoped; import jakarta.enterprise.context.spi.Contextual; import jakarta.enterprise.context.spi.CreationalContext; -import jakarta.enterprise.event.Event; -import jakarta.enterprise.inject.Any; import org.jboss.logging.Logger; -import io.quarkus.arc.Arc; -import io.quarkus.arc.ArcContainer; import io.quarkus.arc.ContextInstanceHandle; import io.quarkus.arc.CurrentContext; -import io.quarkus.arc.CurrentContextFactory; import io.quarkus.arc.InjectableBean; import io.quarkus.arc.ManagedContext; -import io.quarkus.arc.impl.ComputingCacheContextInstances; -import io.quarkus.arc.impl.ContextInstanceHandleImpl; -import io.quarkus.arc.impl.ContextInstances; -import io.quarkus.arc.impl.LazyValue; -public class WebSocketSessionContext implements ManagedContext { +/** + * A managed context backed by the {@link CurrentContext}. + */ +public abstract class CurrentManagedContext implements ManagedContext { - private static final Logger LOG = Logger.getLogger(WebSocketSessionContext.class); + private static final Logger LOG = Logger.getLogger(CurrentManagedContext.class); - private final CurrentContext currentContext; - private final LazyValue> initializedEvent; - private final LazyValue> beforeDestroyEvent; - private final LazyValue> destroyEvent; + private final CurrentContext currentContext; - public WebSocketSessionContext(CurrentContextFactory currentContextFactory) { - this.currentContext = currentContextFactory.create(SessionScoped.class); - this.initializedEvent = newEvent(Initialized.Literal.SESSION, Any.Literal.INSTANCE); - this.beforeDestroyEvent = newEvent(BeforeDestroyed.Literal.SESSION, Any.Literal.INSTANCE); - this.destroyEvent = newEvent(Destroyed.Literal.SESSION, Any.Literal.INSTANCE); - } + private final Supplier contextInstances; - @Override - public Class getScope() { - return SessionScoped.class; + private final Consumer initializedNotifier; + private final Consumer beforeDestroyedNotifier; + private final Consumer destroyedNotifier; + + protected CurrentManagedContext(CurrentContext currentContext, + Supplier contextInstances, Consumer initializedNotifier, + Consumer beforeDestroyedNotifier, Consumer destroyedNotifier) { + this.currentContext = currentContext; + this.contextInstances = contextInstances; + this.initializedNotifier = initializedNotifier; + this.beforeDestroyedNotifier = beforeDestroyedNotifier; + this.destroyedNotifier = destroyedNotifier; } @Override public ContextState getState() { - SessionContextState state = currentState(); + CurrentContextState state = currentState(); if (state == null) { throw notActive(); } @@ -64,13 +56,16 @@ public ContextState getState() { @Override public ContextState activate(ContextState initialState) { + if (traceLog().isTraceEnabled()) { + traceActivate(initialState); + } if (initialState == null) { - SessionContextState state = initializeContextState(); + CurrentContextState state = initializeState(); currentContext.set(state); return state; } else { - if (initialState instanceof SessionContextState) { - currentContext.set((SessionContextState) initialState); + if (initialState instanceof CurrentContextState) { + currentContext.set((CurrentContextState) initialState); return initialState; } else { throw new IllegalArgumentException("Invalid initial state: " + initialState.getClass().getName()); @@ -80,39 +75,58 @@ public ContextState activate(ContextState initialState) { @Override public void deactivate() { + if (traceLog().isTraceEnabled()) { + traceDeactivate(); + } currentContext.remove(); } @SuppressWarnings("unchecked") @Override - public T get(Contextual contextual, CreationalContext creationalContext) { + public T getIfActive(Contextual contextual, Function, CreationalContext> creationalContextFun) { Objects.requireNonNull(contextual, "Contextual must not be null"); - Objects.requireNonNull(creationalContext, "CreationalContext must not be null"); + Objects.requireNonNull(creationalContextFun, "CreationalContext function must not be null"); InjectableBean bean = (InjectableBean) contextual; - if (!SessionScoped.class.getName().equals(bean.getScope().getName())) { - throw invalidScope(); + if (!Scopes.scopeMatches(this, bean)) { + throw Scopes.scopeDoesNotMatchException(this, bean); } - SessionContextState state = currentState(); + CurrentContextState state = currentState(); if (state == null || !state.isValid()) { - throw notActive(); + return null; } - return (T) state.contextInstances.computeIfAbsent(bean.getIdentifier(), new Supplier>() { + ContextInstances contextInstances = state.contextInstances; + ContextInstanceHandle instance = (ContextInstanceHandle) contextInstances.getIfPresent(bean.getIdentifier()); + if (instance == null) { + CreationalContext creationalContext = creationalContextFun.apply(contextual); + return (T) contextInstances.computeIfAbsent(bean.getIdentifier(), new Supplier>() { + + @Override + public ContextInstanceHandle get() { + return new ContextInstanceHandleImpl<>(bean, contextual.create(creationalContext), creationalContext); + } + }).get(); + } + return instance.get(); + } - @Override - public ContextInstanceHandle get() { - return new ContextInstanceHandleImpl<>(bean, contextual.create(creationalContext), creationalContext); - } - }).get(); + @Override + public T get(Contextual contextual, CreationalContext creationalContext) { + T result = getIfActive(contextual, + CreationalContextImpl.unwrap(Objects.requireNonNull(creationalContext, "CreationalContext must not be null"))); + if (result == null) { + throw notActive(); + } + return result; } @Override public T get(Contextual contextual) { Objects.requireNonNull(contextual, "Contextual must not be null"); InjectableBean bean = (InjectableBean) contextual; - if (!SessionScoped.class.getName().equals(bean.getScope().getName())) { - throw invalidScope(); + if (!Scopes.scopeMatches(this, bean)) { + throw Scopes.scopeDoesNotMatchException(this, bean); } - SessionContextState state = currentState(); + CurrentContextState state = currentState(); if (state == null || !state.isValid()) { throw notActive(); } @@ -124,7 +138,7 @@ public T get(Contextual contextual) { @Override public boolean isActive() { - SessionContextState contextState = currentState(); + CurrentContextState contextState = currentState(); return contextState == null ? false : contextState.isValid(); } @@ -135,7 +149,7 @@ public void destroy() { @Override public void destroy(Contextual contextual) { - SessionContextState state = currentState(); + CurrentContextState state = currentState(); if (state == null || !state.isValid()) { throw notActive(); } @@ -148,64 +162,65 @@ public void destroy(Contextual contextual) { @Override public void destroy(ContextState state) { + if (traceLog().isTraceEnabled()) { + traceDestroy(state); + } if (state == null) { // nothing to destroy return; } - if (state instanceof SessionContextState) { - SessionContextState sessionState = ((SessionContextState) state); - if (sessionState.invalidate()) { - fireIfNotNull(beforeDestroyEvent.get()); - sessionState.contextInstances.removeEach(ContextInstanceHandle::destroy); - fireIfNotNull(destroyEvent.get()); + if (state instanceof CurrentContextState) { + CurrentContextState currentState = ((CurrentContextState) state); + if (currentState.invalidate()) { + fireIfNotNull(beforeDestroyedNotifier); + currentState.contextInstances.removeEach(ContextInstanceHandle::destroy); + fireIfNotNull(destroyedNotifier); } } else { throw new IllegalArgumentException("Invalid state implementation: " + state.getClass().getName()); } } - SessionContextState initializeContextState() { - SessionContextState state = new SessionContextState(new ComputingCacheContextInstances()); - fireIfNotNull(initializedEvent.get()); + @Override + public CurrentContextState initializeState() { + CurrentContextState state = new CurrentContextState(contextInstances.get()); + fireIfNotNull(initializedNotifier); return state; } - private SessionContextState currentState() { - return currentContext.get(); + protected Logger traceLog() { + return LOG; + } + + protected void traceActivate(ContextState initialState) { + // Noop + } + + protected void traceDeactivate() { + // Noop } - private IllegalArgumentException invalidScope() { - throw new IllegalArgumentException("The bean does not declare @SessionScoped"); + protected void traceDestroy(ContextState state) { + // Noop } - private ContextNotActiveException notActive() { - return new ContextNotActiveException("Session context is not active"); + private CurrentContextState currentState() { + return currentContext.get(); } - private void fireIfNotNull(Event event) { - if (event != null) { + protected abstract ContextNotActiveException notActive(); + + private void fireIfNotNull(Consumer notifier) { + if (notifier != null) { try { - event.fire(toString()); + notifier.accept(toString()); } catch (Exception e) { LOG.warn("An error occurred during delivery of the context lifecycle event for " + toString(), e); } } } - private static LazyValue> newEvent(Annotation... qualifiers) { - return new LazyValue<>(new Supplier>() { - @Override - public Event get() { - ArcContainer container = Arc.container(); - if (container.resolveObserverMethods(Object.class, qualifiers).isEmpty()) { - return null; - } - return container.beanManager().getEvent().select(qualifiers); - } - }); - } - - static class SessionContextState implements ContextState { + public static class CurrentContextState implements ContextState { // Using 0 as default value enable removing an initialization // in the constructor, piggybacking on the default value. @@ -219,7 +234,7 @@ static class SessionContextState implements ContextState { static { try { - IS_VALID = MethodHandles.lookup().findVarHandle(SessionContextState.class, "isValid", int.class); + IS_VALID = MethodHandles.lookup().findVarHandle(CurrentContextState.class, "isValid", int.class); } catch (ReflectiveOperationException e) { throw new Error(e); } @@ -228,8 +243,8 @@ static class SessionContextState implements ContextState { private final ContextInstances contextInstances; private volatile int isValid; - SessionContextState(ContextInstances contextInstances) { - this.contextInstances = contextInstances; + CurrentContextState(ContextInstances contextInstances) { + this.contextInstances = Objects.requireNonNull(contextInstances); } @Override diff --git a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/RequestContext.java b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/RequestContext.java index 5164d84c374f3..62a1ac4abf829 100644 --- a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/RequestContext.java +++ b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/RequestContext.java @@ -1,26 +1,16 @@ package io.quarkus.arc.impl; import java.lang.annotation.Annotation; -import java.lang.invoke.MethodHandles; -import java.lang.invoke.VarHandle; import java.util.Arrays; -import java.util.Map; -import java.util.Objects; -import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; import jakarta.enterprise.context.ContextNotActiveException; import jakarta.enterprise.context.RequestScoped; -import jakarta.enterprise.context.spi.Contextual; -import jakarta.enterprise.context.spi.CreationalContext; import org.jboss.logging.Logger; -import io.quarkus.arc.ContextInstanceHandle; import io.quarkus.arc.CurrentContext; -import io.quarkus.arc.InjectableBean; -import io.quarkus.arc.ManagedContext; import io.quarkus.arc.impl.EventImpl.Notifier; /** @@ -28,25 +18,16 @@ * * @author Martin Kouba */ -class RequestContext implements ManagedContext { +class RequestContext extends CurrentManagedContext { private static final Logger LOG = Logger.getLogger("io.quarkus.arc.requestContext"); - private final CurrentContext currentContext; - - private final Notifier initializedNotifier; - private final Notifier beforeDestroyedNotifier; - private final Notifier destroyedNotifier; - private final Supplier contextInstances; - - public RequestContext(CurrentContext currentContext, Notifier initializedNotifier, + public RequestContext(CurrentContext currentContext, Notifier initializedNotifier, Notifier beforeDestroyedNotifier, Notifier destroyedNotifier, Supplier contextInstances) { - this.currentContext = currentContext; - this.initializedNotifier = initializedNotifier; - this.beforeDestroyedNotifier = beforeDestroyedNotifier; - this.destroyedNotifier = destroyedNotifier; - this.contextInstances = contextInstances; + super(currentContext, contextInstances, initializedNotifier != null ? initializedNotifier::notify : null, + beforeDestroyedNotifier != null ? beforeDestroyedNotifier::notify : null, + destroyedNotifier != null ? destroyedNotifier::notify : null); } @Override @@ -54,107 +35,12 @@ public Class getScope() { return RequestScoped.class; } - @SuppressWarnings("unchecked") - @Override - public T getIfActive(Contextual contextual, Function, CreationalContext> creationalContextFun) { - Objects.requireNonNull(contextual, "Contextual must not be null"); - Objects.requireNonNull(creationalContextFun, "CreationalContext supplier must not be null"); - InjectableBean bean = (InjectableBean) contextual; - if (!Scopes.scopeMatches(this, bean)) { - throw Scopes.scopeDoesNotMatchException(this, bean); - } - RequestContextState ctxState = currentContext.get(); - if (!isActive(ctxState)) { - // Context is not active! - return null; - } - ContextInstances contextInstances = ctxState.contextInstances; - ContextInstanceHandle instance = (ContextInstanceHandle) contextInstances.getIfPresent(bean.getIdentifier()); - if (instance == null) { - CreationalContext creationalContext = creationalContextFun.apply(contextual); - return (T) contextInstances.computeIfAbsent(bean.getIdentifier(), new Supplier>() { - - @Override - public ContextInstanceHandle get() { - return new ContextInstanceHandleImpl<>(bean, contextual.create(creationalContext), creationalContext); - } - }).get(); - } - return instance.get(); - } - - @Override - public T get(Contextual contextual, CreationalContext creationalContext) { - T result = getIfActive(contextual, - CreationalContextImpl.unwrap(Objects.requireNonNull(creationalContext, "CreationalContext must not be null"))); - if (result == null) { - throw notActive(); - } - return result; - } - - @SuppressWarnings("unchecked") - @Override - public T get(Contextual contextual) { - Objects.requireNonNull(contextual, "Contextual must not be null"); - InjectableBean bean = (InjectableBean) contextual; - if (!Scopes.scopeMatches(this, bean)) { - throw Scopes.scopeDoesNotMatchException(this, bean); - } - RequestContextState state = currentContext.get(); - if (!isActive(state)) { - throw notActive(); - } - ContextInstanceHandle instance = (ContextInstanceHandle) state.contextInstances - .getIfPresent(bean.getIdentifier()); - return instance == null ? null : instance.get(); - } - @Override - public boolean isActive() { - return isActive(currentContext.get()); + protected Logger traceLog() { + return LOG; } - private boolean isActive(RequestContextState state) { - return state == null ? false : state.isValid(); - } - - @Override - public void destroy(Contextual contextual) { - RequestContextState state = currentContext.get(); - if (!isActive(state)) { - // Context is not active - throw notActive(); - } - InjectableBean bean = (InjectableBean) contextual; - ContextInstanceHandle instance = state.contextInstances.remove(bean.getIdentifier()); - if (instance != null) { - instance.destroy(); - } - } - - @Override - public ContextState activate(ContextState initialState) { - if (LOG.isTraceEnabled()) { - traceActivate(initialState); - } - if (initialState == null) { - RequestContextState state = new RequestContextState(contextInstances.get()); - currentContext.set(state); - // Fire an event with qualifier @Initialized(RequestScoped.class) if there are any observers for it - fireIfNotEmpty(initializedNotifier); - return state; - } else { - if (initialState instanceof RequestContextState) { - currentContext.set((RequestContextState) initialState); - return initialState; - } else { - throw new IllegalArgumentException("Invalid initial state: " + initialState.getClass().getName()); - } - } - } - - private void traceActivate(ContextState initialState) { + protected void traceActivate(ContextState initialState) { String stack = Arrays.stream(Thread.currentThread().getStackTrace()) .skip(2) .limit(7) @@ -164,29 +50,7 @@ private void traceActivate(ContextState initialState) { initialState != null ? Integer.toHexString(initialState.hashCode()) : "new", stack); } - @Override - public ContextState getState() { - RequestContextState state = currentContext.get(); - if (!isActive(state)) { - throw notActive(); - } - return state; - } - - public ContextState getStateIfActive() { - ContextState state = currentContext.get(); - return state != null && state.isValid() ? state : null; - } - - @Override - public void deactivate() { - if (LOG.isTraceEnabled()) { - traceDeactivate(); - } - currentContext.remove(); - } - - private static void traceDeactivate() { + protected void traceDeactivate() { String stack = Arrays.stream(Thread.currentThread().getStackTrace()) .skip(2) .limit(7) @@ -195,35 +59,7 @@ private static void traceDeactivate() { LOG.tracef("Deactivate%s\n\t...", stack); } - @Override - public void destroy() { - destroy(currentContext.get()); - } - - @Override - public void destroy(ContextState state) { - if (LOG.isTraceEnabled()) { - traceDestroy(state); - } - if (state == null) { - // nothing to destroy - return; - } - if (state instanceof RequestContextState) { - RequestContextState reqState = ((RequestContextState) state); - if (reqState.invalidate()) { - // Fire an event with qualifier @BeforeDestroyed(RequestScoped.class) if there are any observers for it - fireIfNotEmpty(beforeDestroyedNotifier); - reqState.contextInstances.removeEach(ContextInstanceHandle::destroy); - // Fire an event with qualifier @Destroyed(RequestScoped.class) if there are any observers for it - fireIfNotEmpty(destroyedNotifier); - } - } else { - throw new IllegalArgumentException("Invalid state implementation: " + state.getClass().getName()); - } - } - - private static void traceDestroy(ContextState state) { + protected void traceDestroy(ContextState state) { String stack = Arrays.stream(Thread.currentThread().getStackTrace()) .skip(2) .limit(7) @@ -232,68 +68,9 @@ private static void traceDestroy(ContextState state) { LOG.tracef("Destroy %s%s\n\t...", state != null ? Integer.toHexString(state.hashCode()) : "", stack); } - private void fireIfNotEmpty(Notifier notifier) { - if (notifier != null && !notifier.isEmpty()) { - try { - notifier.notify(toString()); - } catch (Exception e) { - LOG.warn("An error occurred during delivery of the container lifecycle event for qualifiers " - + notifier.eventMetadata.getQualifiers(), e); - } - } - } - - private ContextNotActiveException notActive() { + protected ContextNotActiveException notActive() { String msg = "Request context is not active - you can activate the request context for a specific method using the @ActivateRequestContext interceptor binding"; return new ContextNotActiveException(msg); } - static class RequestContextState implements ContextState { - - // Using 0 as default value enable removing an initialization - // in the constructor, piggybacking on the default value. - // As per https://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.5 - // the default field values are set before 'this' is accessible, hence - // they should be the very first value observable even in presence of - // unsafe publication of this object. - private static final int VALID = 0; - private static final int INVALID = 1; - private static final VarHandle IS_VALID; - - static { - try { - IS_VALID = MethodHandles.lookup().findVarHandle(RequestContextState.class, "isValid", int.class); - } catch (ReflectiveOperationException e) { - throw new Error(e); - } - } - - private final ContextInstances contextInstances; - private volatile int isValid; - - RequestContextState(ContextInstances contextInstances) { - this.contextInstances = Objects.requireNonNull(contextInstances); - } - - @Override - public Map, Object> getContextualInstances() { - return contextInstances.getAllPresent().stream() - .collect(Collectors.toUnmodifiableMap(ContextInstanceHandle::getBean, ContextInstanceHandle::get)); - } - - /** - * @return {@code true} if the state was successfully invalidated, {@code false} otherwise - */ - boolean invalidate() { - // Atomically sets the value just like AtomicBoolean.compareAndSet(boolean, boolean) - return IS_VALID.compareAndSet(this, VALID, INVALID); - } - - @Override - public boolean isValid() { - return isValid == VALID; - } - - } - } diff --git a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/SessionContext.java b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/SessionContext.java new file mode 100644 index 0000000000000..fb44039c68305 --- /dev/null +++ b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/SessionContext.java @@ -0,0 +1,35 @@ +package io.quarkus.arc.impl; + +import java.lang.annotation.Annotation; +import java.util.function.Supplier; + +import jakarta.enterprise.context.ContextNotActiveException; +import jakarta.enterprise.context.SessionScoped; + +import io.quarkus.arc.CurrentContext; +import io.quarkus.arc.impl.EventImpl.Notifier; + +/** + * The built-in context for {@link SessionScoped}. + */ +public class SessionContext extends CurrentManagedContext { + + public SessionContext(CurrentContext currentContext, Notifier initializedNotifier, + Notifier beforeDestroyedNotifier, Notifier destroyedNotifier, + Supplier contextInstances) { + super(currentContext, contextInstances, initializedNotifier != null ? initializedNotifier::notify : null, + beforeDestroyedNotifier != null ? beforeDestroyedNotifier::notify : null, + destroyedNotifier != null ? destroyedNotifier::notify : null); + } + + @Override + public Class getScope() { + return SessionScoped.class; + } + + @Override + protected ContextNotActiveException notActive() { + return new ContextNotActiveException("Session context is not active"); + } + +} diff --git a/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/buildextension/injectionPoints/RepeatingQualifiersInjectionPointTransformerTest.java b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/buildextension/injectionPoints/RepeatingQualifiersInjectionPointTransformerTest.java new file mode 100644 index 0000000000000..0ff289cf9fd52 --- /dev/null +++ b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/buildextension/injectionPoints/RepeatingQualifiersInjectionPointTransformerTest.java @@ -0,0 +1,126 @@ +package io.quarkus.arc.test.buildextension.injectionPoints; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Inherited; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.enterprise.util.AnnotationLiteral; +import jakarta.inject.Qualifier; +import jakarta.inject.Singleton; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.ClassType; +import org.jboss.jandex.DotName; +import org.jboss.jandex.Type; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.processor.InjectionPointsTransformer; +import io.quarkus.arc.test.ArcTestContainer; + +public class RepeatingQualifiersInjectionPointTransformerTest { + + public static final String FIRST_STRING = "neverwhere"; + public static final String SECOND_STRING = "london"; + + @RegisterExtension + public ArcTestContainer container = ArcTestContainer.builder() + .beanClasses(Foo.class, Bar.class, Location.class, Locations.class, ShapeableBean.class) + .injectionPointsTransformers(new MyTransformer()) + .build(); + + @Test + public void testQualifiersHandledCorrectly() { + Assertions.assertTrue(Arc.container().select(ShapeableBean.class).isResolvable()); + } + + @Inherited + @Target({ TYPE, METHOD, FIELD, PARAMETER }) + @Retention(RUNTIME) + public @interface Locations { + Location[] value(); + } + + @Qualifier + @Inherited + @Target({ TYPE, METHOD, FIELD, PARAMETER }) + @Retention(RUNTIME) + @Repeatable(Locations.class) + public @interface Location { + String value(); + + class Literal extends AnnotationLiteral implements Location { + + private final String value; + + public Literal(String value) { + this.value = value; + } + + @Override + public String value() { + return value; + } + } + } + + @Singleton + public static class Foo { + + } + + @Singleton + @Location(FIRST_STRING) + @Location(SECOND_STRING) + public static class Bar { + + } + + @Singleton + public static class ShapeableBean { + + // Bar bean exists only with repeated qualifiers - transformer adds those + // Foo bean exists only without qualifiers - transformer removes all qualifiers + public ShapeableBean(@Location("doesn't") @Location("matter") Foo foo, Bar bar) { + + } + } + + static class MyTransformer implements InjectionPointsTransformer { + + @Override + public boolean appliesTo(Type requiredType) { + // applies to all Foo/Bar injection points + return requiredType.equals(ClassType.create(Foo.class)) || requiredType.equals(ClassType.create(Bar.class)); + } + + @Override + public void transform(TransformationContext transformationContext) { + if (AnnotationTarget.Kind.METHOD_PARAMETER == transformationContext.getAnnotationTarget().kind()) { + if (transformationContext.getAnnotationTarget().asMethodParameter().type().name() + .equals(DotName.createSimple(Foo.class))) { + transformationContext.transform().removeAll().done(); + } else { + // add repeating qualifiers + transformationContext.transform() + .add(AnnotationInstance.builder(Location.class).value(FIRST_STRING).build()) + .add(AnnotationInstance.builder(Location.class).value(SECOND_STRING).build()) + .done(); + } + } else { + throw new IllegalStateException( + "Unexpected injection point kind: " + transformationContext.getAnnotationTarget().kind()); + } + } + } +} diff --git a/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/session/ContextObserver.java b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/session/ContextObserver.java new file mode 100644 index 0000000000000..375d944058227 --- /dev/null +++ b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/session/ContextObserver.java @@ -0,0 +1,34 @@ +package io.quarkus.arc.test.contexts.session; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.BeforeDestroyed; +import jakarta.enterprise.context.Destroyed; +import jakarta.enterprise.context.Initialized; +import jakarta.enterprise.context.SessionScoped; +import jakarta.enterprise.event.Observes; + +@ApplicationScoped +public class ContextObserver { + + static volatile int initializedObserved = 0; + static volatile int beforeDestroyedObserved = 0; + static volatile int destroyedObserved = 0; + + static void reset() { + initializedObserved = 0; + beforeDestroyedObserved = 0; + destroyedObserved = 0; + } + + void observeContextInit(@Observes @Initialized(SessionScoped.class) Object event) { + initializedObserved++; + } + + void observeContextBeforeDestroyed(@Observes @BeforeDestroyed(SessionScoped.class) Object event) { + beforeDestroyedObserved++; + } + + void observeContextDestroyed(@Observes @Destroyed(SessionScoped.class) Object event) { + destroyedObserved++; + } +} diff --git a/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/session/Controller.java b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/session/Controller.java new file mode 100644 index 0000000000000..5fabd0f130e50 --- /dev/null +++ b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/session/Controller.java @@ -0,0 +1,30 @@ +package io.quarkus.arc.test.contexts.session; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.SessionScoped; + +@SessionScoped +public class Controller { + + static final AtomicBoolean DESTROYED = new AtomicBoolean(); + + private String id; + + @PostConstruct + void init() { + id = UUID.randomUUID().toString(); + } + + @PreDestroy + void destroy() { + DESTROYED.set(true); + } + + String getId() { + return id; + } +} diff --git a/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/session/SessionContextTest.java b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/session/SessionContextTest.java new file mode 100644 index 0000000000000..871deb5d45be3 --- /dev/null +++ b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/session/SessionContextTest.java @@ -0,0 +1,100 @@ +package io.quarkus.arc.test.contexts.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import jakarta.enterprise.context.ContextNotActiveException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcContainer; +import io.quarkus.arc.ManagedContext; +import io.quarkus.arc.test.ArcTestContainer; + +public class SessionContextTest { + + @RegisterExtension + public ArcTestContainer container = new ArcTestContainer(Controller.class, ContextObserver.class); + + @Test + public void testSessionContext() { + Controller.DESTROYED.set(false); + ArcContainer arc = Arc.container(); + ManagedContext sessionContext = arc.sessionContext(); + + try { + arc.instance(Controller.class).get().getId(); + fail(); + } catch (ContextNotActiveException expected) { + } + + sessionContext.activate(); + assertFalse(Controller.DESTROYED.get()); + Controller controller1 = arc.instance(Controller.class).get(); + Controller controller2 = arc.instance(Controller.class).get(); + String controller2Id = controller2.getId(); + assertEquals(controller1.getId(), controller2Id); + sessionContext.terminate(); + assertTrue(Controller.DESTROYED.get()); + + try { + arc.instance(Controller.class).get().getId(); + fail(); + } catch (ContextNotActiveException expected) { + } + + // Id must be different in a different context + Controller.DESTROYED.set(false); + sessionContext.activate(); + assertNotEquals(controller2Id, arc.instance(Controller.class).get().getId()); + sessionContext.terminate(); + assertTrue(Controller.DESTROYED.get()); + + Controller.DESTROYED.set(false); + sessionContext.activate(); + assertNotEquals(controller2Id, arc.instance(Controller.class).get().getId()); + sessionContext.terminate(); + assertTrue(Controller.DESTROYED.get()); + } + + @Test + public void testSessionContextEvents() { + // reset counters since other tests might have triggered it already + ContextObserver.reset(); + + // firstly test manual activation + ArcContainer arc = Arc.container(); + ManagedContext sessionContext = arc.sessionContext(); + + try { + arc.instance(Controller.class).get().getId(); + fail(); + } catch (ContextNotActiveException expected) { + } + + sessionContext.activate(); + assertEquals(1, ContextObserver.initializedObserved); + assertEquals(0, ContextObserver.beforeDestroyedObserved); + assertEquals(0, ContextObserver.destroyedObserved); + + // dummy check that bean is available + arc.instance(Controller.class).get().getId(); + + sessionContext.terminate(); + assertEquals(1, ContextObserver.initializedObserved); + assertEquals(1, ContextObserver.beforeDestroyedObserved); + assertEquals(1, ContextObserver.destroyedObserved); + + try { + arc.instance(Controller.class).get().getId(); + fail(); + } catch (ContextNotActiveException expected) { + } + } + +} diff --git a/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/interceptors/producer/InterceptionProxySubclassNormalScopedTest.java b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/interceptors/producer/InterceptionProxySubclassNormalScopedTest.java new file mode 100644 index 0000000000000..e636ea4d69908 --- /dev/null +++ b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/interceptors/producer/InterceptionProxySubclassNormalScopedTest.java @@ -0,0 +1,83 @@ +package io.quarkus.arc.test.interceptors.producer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Dependent; +import jakarta.enterprise.inject.Produces; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InterceptorBinding; +import jakarta.interceptor.InvocationContext; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ClientProxy; +import io.quarkus.arc.InterceptionProxy; +import io.quarkus.arc.InterceptionProxySubclass; +import io.quarkus.arc.test.ArcTestContainer; + +public class InterceptionProxySubclassNormalScopedTest { + @RegisterExtension + public ArcTestContainer container = new ArcTestContainer(MyBinding.class, MyInterceptor.class, MyProducer.class); + + @Test + public void test() { + MyNonbean nonbean = Arc.container().instance(MyNonbean.class).get(); + assertEquals("intercepted: hello", nonbean.hello()); + + assertInstanceOf(ClientProxy.class, nonbean); + assertNotNull(ClientProxy.unwrap(nonbean)); + assertNotSame(nonbean, ClientProxy.unwrap(nonbean)); + + MyNonbean unwrapped = ClientProxy.unwrap(nonbean); + + assertInstanceOf(InterceptionProxySubclass.class, unwrapped); + assertNotNull(InterceptionProxySubclass.unwrap(unwrapped)); + assertNotSame(unwrapped, InterceptionProxySubclass.unwrap(unwrapped)); + assertNotSame(nonbean, InterceptionProxySubclass.unwrap(unwrapped)); + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR }) + @InterceptorBinding + @interface MyBinding { + } + + @MyBinding + @Priority(1) + @Interceptor + static class MyInterceptor { + @AroundInvoke + Object intercept(InvocationContext ctx) throws Exception { + return "intercepted: " + ctx.proceed(); + } + } + + static class MyNonbean { + @MyBinding + String hello() { + return "hello"; + } + } + + @Dependent + static class MyProducer { + @Produces + @ApplicationScoped + MyNonbean produce(InterceptionProxy proxy) { + return proxy.create(new MyNonbean()); + } + } +} diff --git a/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/interceptors/producer/InterceptionProxySubclassPseudoScopedTest.java b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/interceptors/producer/InterceptionProxySubclassPseudoScopedTest.java new file mode 100644 index 0000000000000..8babcdb5ca801 --- /dev/null +++ b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/interceptors/producer/InterceptionProxySubclassPseudoScopedTest.java @@ -0,0 +1,75 @@ +package io.quarkus.arc.test.interceptors.producer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.annotation.Priority; +import jakarta.enterprise.context.Dependent; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InterceptorBinding; +import jakarta.interceptor.InvocationContext; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.InterceptionProxy; +import io.quarkus.arc.InterceptionProxySubclass; +import io.quarkus.arc.test.ArcTestContainer; + +public class InterceptionProxySubclassPseudoScopedTest { + @RegisterExtension + public ArcTestContainer container = new ArcTestContainer(MyBinding.class, MyInterceptor.class, MyProducer.class); + + @Test + public void test() { + MyNonbean nonbean = Arc.container().instance(MyNonbean.class).get(); + assertEquals("intercepted: hello", nonbean.hello()); + + assertInstanceOf(InterceptionProxySubclass.class, nonbean); + assertNotNull(InterceptionProxySubclass.unwrap(nonbean)); + assertNotSame(nonbean, InterceptionProxySubclass.unwrap(nonbean)); + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR }) + @InterceptorBinding + @interface MyBinding { + } + + @MyBinding + @Priority(1) + @Interceptor + static class MyInterceptor { + @AroundInvoke + Object intercept(InvocationContext ctx) throws Exception { + return "intercepted: " + ctx.proceed(); + } + } + + static class MyNonbean { + @MyBinding + String hello() { + return "hello"; + } + } + + @Dependent + static class MyProducer { + @Produces + @Singleton + MyNonbean produce(InterceptionProxy proxy) { + return proxy.create(new MyNonbean()); + } + } +} diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/ApplicationModel.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/ApplicationModel.java index d9eb54f92c73e..76e223bf92fdc 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/ApplicationModel.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/ApplicationModel.java @@ -40,13 +40,30 @@ public interface ApplicationModel { */ Iterable getDependencies(int flags); + /** + * Returns application dependencies that have any of the flags combined in the value of the {@code flags} arguments set. + * + * @param flags dependency flags to match + * @return application dependencies that matched the flags + */ + Iterable getDependenciesWithAnyFlag(int flags); + /** * Returns application dependencies that have any of the flags passed in as arguments set. * * @param flags dependency flags to match * @return application dependencies that matched the flags */ - Iterable getDependenciesWithAnyFlag(int... flags); + default Iterable getDependenciesWithAnyFlag(int... flags) { + if (flags.length == 0) { + throw new IllegalArgumentException("Flags are empty"); + } + int combined = flags[0]; + for (int i = 1; i < flags.length; ++i) { + combined |= flags[i]; + } + return getDependenciesWithAnyFlag(combined); + } /** * Runtime dependencies of an application 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 7689f9aa6eb9e..290a1241c10ab 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 @@ -298,15 +298,6 @@ private static GACT toArtifactKey(String artifact) { return new GACT(artifact.split(":")); } - private static boolean matches(ArtifactCoordsPattern[] patterns, ArtifactCoords coords) { - for (int i = 0; i < patterns.length; ++i) { - if (patterns[i].matches(coords)) { - return true; - } - } - return false; - } - List buildDependencies() { for (ArtifactKey key : parentFirstArtifacts) { final ResolvedDependencyBuilder d = dependencies.get(key); @@ -330,13 +321,47 @@ List buildDependencies() { final List result = new ArrayList<>(dependencies.size()); final ArtifactCoordsPattern[] excludePatterns = excludedArtifacts.toArray(new ArtifactCoordsPattern[0]); for (ResolvedDependencyBuilder db : this.dependencies.values()) { - if (!matches(excludePatterns, db.getArtifactCoords())) { + if (!matches(db.getArtifactCoords(), excludePatterns)) { + db.setDependencies(ensureNoMatches(db.getDependencies(), excludePatterns)); result.add(db.build()); } } return result; } + private static boolean matches(ArtifactCoords coords, ArtifactCoordsPattern[] patterns) { + for (int i = 0; i < patterns.length; ++i) { + if (patterns[i].matches(coords)) { + return true; + } + } + return false; + } + + private static Collection ensureNoMatches(Collection artifacts, + ArtifactCoordsPattern[] patterns) { + if (artifacts.isEmpty() || patterns.length == 0) { + return artifacts; + } + for (var dep : artifacts) { + if (matches(dep, patterns)) { + return excludeMatches(artifacts, patterns); + } + } + return artifacts; + } + + private static Collection excludeMatches(Collection artifacts, + ArtifactCoordsPattern[] patterns) { + final List result = new ArrayList<>(artifacts.size() - 1); + for (var artifact : artifacts) { + if (!matches(artifact, patterns)) { + result.add(artifact); + } + } + return result; + } + public DefaultApplicationModel build() { return new DefaultApplicationModel(this); } diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/DefaultApplicationModel.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/DefaultApplicationModel.java index 3e6cc090723d6..08246386a6c0d 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/DefaultApplicationModel.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/DefaultApplicationModel.java @@ -53,11 +53,12 @@ public Collection getRuntimeDependencies() { @Override public Iterable getDependencies(int flags) { - return new FlagDependencyIterator(new int[] { flags }); + return new FlagDependencyIterator(flags, false); } - public Iterable getDependenciesWithAnyFlag(int... flags) { - return new FlagDependencyIterator(flags); + @Override + public Iterable getDependenciesWithAnyFlag(int flags) { + return new FlagDependencyIterator(flags, true); } @Override @@ -118,10 +119,20 @@ private Set collectKeys(int flags) { private class FlagDependencyIterator implements Iterable { - private final int[] flags; - - private FlagDependencyIterator(int[] flags) { + private final int flags; + private final boolean any; + + /** + * Iterates over application model dependencies that match requested flags. + * The {@code any} boolean argument controls whether any or all the flags have to match + * for a dependency to be selected. + * + * @param flags flags to match + * @param any whether any or all of the flags have to be matched + */ + private FlagDependencyIterator(int flags, boolean any) { this.flags = flags; + this.any = any; } @Override @@ -152,11 +163,14 @@ public ResolvedDependency next() { private void moveOn() { next = null; - while (index < dependencies.size()) { + while (index < dependencies.size() && next == null) { var d = dependencies.get(index++); - if (d.hasAnyFlag(flags)) { + if (any) { + if (d.isAnyFlagSet(flags)) { + next = d; + } + } else if (d.isFlagSet(flags)) { next = d; - break; } } } diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/util/BootstrapUtils.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/util/BootstrapUtils.java index 43602918e8e46..21659ce8d9569 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/util/BootstrapUtils.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/util/BootstrapUtils.java @@ -9,14 +9,22 @@ import java.nio.file.Path; import java.util.regex.Pattern; +import org.jboss.logging.Logger; + import io.quarkus.bootstrap.BootstrapConstants; import io.quarkus.bootstrap.model.ApplicationModel; import io.quarkus.bootstrap.resolver.AppModelResolverException; import io.quarkus.maven.dependency.ArtifactKey; +import io.quarkus.maven.dependency.DependencyFlags; import io.quarkus.maven.dependency.GACT; +import io.quarkus.maven.dependency.ResolvedDependency; public class BootstrapUtils { + private static final Logger log = Logger.getLogger(BootstrapUtils.class); + + private static final int CP_CACHE_FORMAT_ID = 2; + private static Pattern splitByWs; public static String[] splitByWhitespace(String s) { @@ -81,7 +89,90 @@ public static ApplicationModel deserializeQuarkusModel(Path modelPath) throws Ap throw new AppModelResolverException("Unable to locate quarkus model"); } + /** + * Returns a location where a serialized {@link ApplicationModel} would be found for dev mode. + * + * @param projectBuildDir project build directory + * @return file of a serialized application model for dev mode + */ public static Path resolveSerializedAppModelPath(Path projectBuildDir) { - return projectBuildDir.resolve("quarkus").resolve("bootstrap").resolve("dev-app-model.dat"); + return getBootstrapBuildDir(projectBuildDir).resolve("dev-app-model.dat"); + } + + /** + * Returns a location where a serialized {@link ApplicationModel} would be found for test mode. + * + * @param projectBuildDir project build directory + * @return file of a serialized application model for test mode + */ + public static Path getSerializedTestAppModelPath(Path projectBuildDir) { + return getBootstrapBuildDir(projectBuildDir).resolve("test-app-model.dat"); + } + + private static Path getBootstrapBuildDir(Path projectBuildDir) { + return projectBuildDir.resolve("quarkus").resolve("bootstrap"); + } + + /** + * Serializes an {@link ApplicationModel} along with the workspace ID for which it was resolved. + * The serialization format will be different from the one used by {@link #resolveSerializedAppModelPath(Path)} + * and {@link #getSerializedTestAppModelPath(Path)}. + * + * @param appModel application model to serialize + * @param workspaceId workspace ID + * @param file target file + * @throws IOException in case of an IO failure + */ + public static void writeAppModelWithWorkspaceId(ApplicationModel appModel, int workspaceId, Path file) throws IOException { + Files.createDirectories(file.getParent()); + try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(file))) { + out.writeInt(CP_CACHE_FORMAT_ID); + out.writeInt(workspaceId); + out.writeObject(appModel); + } + log.debugf("Serialized application model to %s", file); + } + + /** + * Deserializes an {@link ApplicationModel} from a file. + *

    + * The implementation will check whether the serialization format of the file matches the expected one. + * If it does not, the method will return null even if the file exists. + *

    + * The implementation will compare the deserialized workspace ID to the argument {@code workspaceId} + * and if they don't match the method will return null. + *

    + * Once the {@link ApplicationModel} was deserialized, the dependency paths will be checked for existence. + * If a dependency path does not exist, the method will throw an exception. + * + * @param file serialized application model file + * @param workspaceId expected workspace ID + * @return deserialized application model + * @throws ClassNotFoundException in case a required class could not be loaded + * @throws IOException in case of an IO failure + */ + public static ApplicationModel readAppModelWithWorkspaceId(Path file, int workspaceId) + throws ClassNotFoundException, IOException { + try (ObjectInputStream reader = new ObjectInputStream(Files.newInputStream(file))) { + if (reader.readInt() == CP_CACHE_FORMAT_ID) { + if (reader.readInt() == workspaceId) { + final ApplicationModel appModel = (ApplicationModel) reader.readObject(); + log.debugf("Loaded application model %s from %s", appModel, file); + for (ResolvedDependency d : appModel.getDependencies(DependencyFlags.DEPLOYMENT_CP)) { + for (Path p : d.getResolvedPaths()) { + if (!Files.exists(p)) { + throw new IOException("Cached artifact does not exist: " + p); + } + } + } + return appModel; + } else { + log.debugf("Application model saved in %s has a different workspace ID", file); + } + } else { + log.debugf("Unsupported application model serialization format in %s", file); + } + } + return null; } } diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/Dependency.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/Dependency.java index 2a95f37474117..2a6df30caab3c 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/Dependency.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/Dependency.java @@ -62,14 +62,27 @@ default boolean isClassLoaderParentFirst() { /** * Checks whether a dependency has a given flag set. + * If the value of the {@code flag} argument combines multiple flags, + * the implementation will return {@code true} only if the dependency + * has all the flags set. * - * @param flag flag to check + * @param flag flag (or flags) to check * @return true if the flag is set, otherwise false */ default boolean isFlagSet(int flag) { return (getFlags() & flag) == flag; } + /** + * Checks whether a dependency has any of the flags combined in the value of {@code flags} set. + * + * @param flags flags to check + * @return true, if any of the flags is set, otherwise - false + */ + default boolean isAnyFlagSet(int flags) { + return (getFlags() & flags) > 0; + } + /** * Checks whether any of the flags are set on a dependency * diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/ResolvedDependencyBuilder.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/ResolvedDependencyBuilder.java index 1f6d115bcc84a..f4a82193cc107 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/ResolvedDependencyBuilder.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/ResolvedDependencyBuilder.java @@ -19,7 +19,7 @@ public static ResolvedDependencyBuilder newInstance() { PathCollection resolvedPaths; WorkspaceModule workspaceModule; private volatile ArtifactCoords coords; - private Set deps = Set.of(); + private Collection deps = Set.of(); @Override public PathCollection getResolvedPaths() { @@ -74,6 +74,11 @@ public ResolvedDependencyBuilder addDependencies(Collection deps return this; } + public ResolvedDependencyBuilder setDependencies(Collection deps) { + this.deps = deps; + return this; + } + @Override public Collection getDependencies() { return deps; diff --git a/independent-projects/bootstrap/core/pom.xml b/independent-projects/bootstrap/core/pom.xml index 1fd1e4571a880..979084d2e47d5 100644 --- a/independent-projects/bootstrap/core/pom.xml +++ b/independent-projects/bootstrap/core/pom.xml @@ -60,6 +60,11 @@ junit-jupiter test + + org.assertj + assertj-core + test + org.jboss.shrinkwrap shrinkwrap-depchain diff --git a/independent-projects/bootstrap/core/src/main/java/io/quarkus/bootstrap/BootstrapAppModelFactory.java b/independent-projects/bootstrap/core/src/main/java/io/quarkus/bootstrap/BootstrapAppModelFactory.java index a748ea5a4b732..a60623be2e5d7 100644 --- a/independent-projects/bootstrap/core/src/main/java/io/quarkus/bootstrap/BootstrapAppModelFactory.java +++ b/independent-projects/bootstrap/core/src/main/java/io/quarkus/bootstrap/BootstrapAppModelFactory.java @@ -1,11 +1,11 @@ package io.quarkus.bootstrap; -import java.io.DataInputStream; -import java.io.DataOutputStream; +import static io.quarkus.bootstrap.util.BootstrapUtils.readAppModelWithWorkspaceId; +import static io.quarkus.bootstrap.util.BootstrapUtils.writeAppModelWithWorkspaceId; + import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -30,6 +30,7 @@ import io.quarkus.bootstrap.resolver.maven.workspace.LocalProject; import io.quarkus.bootstrap.resolver.maven.workspace.LocalWorkspace; import io.quarkus.bootstrap.resolver.maven.workspace.ModelUtils; +import io.quarkus.bootstrap.util.BootstrapUtils; import io.quarkus.bootstrap.util.IoUtils; import io.quarkus.maven.dependency.ArtifactCoords; import io.quarkus.maven.dependency.ArtifactKey; @@ -52,8 +53,6 @@ public class BootstrapAppModelFactory { public static final String CREATOR_APP_TYPE = "creator.app.type"; public static final String CREATOR_APP_VERSION = "creator.app.version"; - private static final int CP_CACHE_FORMAT_ID = 2; - private static final Logger log = Logger.getLogger(BootstrapAppModelFactory.class); public static BootstrapAppModelFactory newInstance() { @@ -207,35 +206,26 @@ private BootstrapMavenContext createBootstrapMavenContext() throws AppModelResol } public CurationResult resolveAppModel() throws BootstrapException { - // gradle tests and dev encode the result on the class path - final String serializedModel; - if (test) { - serializedModel = System.getProperty(BootstrapConstants.SERIALIZED_TEST_APP_MODEL); - } else { - serializedModel = System.getProperty(BootstrapConstants.SERIALIZED_APP_MODEL); + CurationResult result = loadFromSystemProperty(); + if (result != null) { + return result; } - if (serializedModel != null) { - final Path p = Paths.get(serializedModel); - if (Files.exists(p)) { - try (InputStream existing = Files.newInputStream(p)) { - final ApplicationModel appModel = (ApplicationModel) new ObjectInputStream(existing).readObject(); - return new CurationResult(appModel); - } catch (IOException | ClassNotFoundException e) { - log.error("Failed to load serialized app mode", e); - } - IoUtils.recursiveDelete(p); - } else { - log.error("Failed to locate serialized application model at " + serializedModel); - } + result = createAppModelForJarOrNull(projectRoot); + if (result != null) { + return result; } - // Massive hack to dected zipped/jar - if (projectRoot != null - && (!Files.isDirectory(projectRoot) || projectRoot.getFileSystem().getClass().getName().contains("Zip"))) { - return createAppModelForJar(projectRoot); - } + return resolveAppModelForWorkspace(); + } + /** + * Resolves an application for a project in a workspace. + * + * @return application model + * @throws BootstrapException in case of a failure + */ + private CurationResult resolveAppModelForWorkspace() throws BootstrapException { ResolvedDependency appArtifact = this.appArtifact; try { LocalProject localProject = null; @@ -264,27 +254,10 @@ public CurationResult resolveAppModel() throws BootstrapException { cachedCpPath = resolveCachedCpPath(localProject); if (Files.exists(cachedCpPath) && workspace.getLastModified() < Files.getLastModifiedTime(cachedCpPath).toMillis()) { - try (DataInputStream reader = new DataInputStream(Files.newInputStream(cachedCpPath))) { - if (reader.readInt() == CP_CACHE_FORMAT_ID) { - if (reader.readInt() == workspace.getId()) { - ObjectInputStream in = new ObjectInputStream(reader); - ApplicationModel appModel = (ApplicationModel) in.readObject(); - - log.debugf("Loaded cached AppModel %s from %s", appModel, cachedCpPath); - for (ResolvedDependency d : appModel.getDependencies()) { - for (Path p : d.getResolvedPaths()) { - if (!Files.exists(p)) { - throw new IOException("Cached artifact does not exist: " + p); - } - } - } - return new CurationResult(appModel); - } else { - debug("Cached deployment classpath has expired for %s", appArtifact); - } - } else { - debug("Unsupported classpath cache format in %s for %s", cachedCpPath, - appArtifact); + try { + final ApplicationModel appModel = readAppModelWithWorkspaceId(cachedCpPath, workspace.getId()); + if (appModel != null) { + return new CurationResult(appModel); } } catch (IOException e) { log.warn("Failed to read deployment classpath cache from " + cachedCpPath + " for " @@ -296,12 +269,9 @@ public CurationResult resolveAppModel() throws BootstrapException { .resolveManagedModel(appArtifact, forcedDependencies, managingProject, reloadableModules)); if (cachedCpPath != null) { Files.createDirectories(cachedCpPath.getParent()); - try (DataOutputStream out = new DataOutputStream(Files.newOutputStream(cachedCpPath))) { - out.writeInt(CP_CACHE_FORMAT_ID); - out.writeInt(workspace.getId()); - ObjectOutputStream obj = new ObjectOutputStream(out); - obj.writeObject(curationResult.getApplicationModel()); - } catch (Exception e) { + try { + writeAppModelWithWorkspaceId(curationResult.getApplicationModel(), workspace.getId(), cachedCpPath); + } catch (IOException e) { log.warn("Failed to write classpath cache", e); } } @@ -311,6 +281,37 @@ public CurationResult resolveAppModel() throws BootstrapException { } } + /** + * Attempts to load an application model from a file system path set as a value of a system property. + * In test mode the system property will be {@link BootstrapConstants#SERIALIZED_TEST_APP_MODEL}, otherwise + * it will be {@link BootstrapConstants#SERIALIZED_APP_MODEL}. + *

    + * If the property was not set, the method will return null. + *

    + * If the model could not deserialized, an error will be logged and null returned. + * + * @return deserialized application model or null + */ + private CurationResult loadFromSystemProperty() { + // gradle tests and dev encode the result on the class path + final String serializedModel = test ? System.getProperty(BootstrapConstants.SERIALIZED_TEST_APP_MODEL) + : System.getProperty(BootstrapConstants.SERIALIZED_APP_MODEL); + if (serializedModel != null) { + final Path p = Paths.get(serializedModel); + if (Files.exists(p)) { + try (InputStream existing = Files.newInputStream(p)) { + return new CurationResult((ApplicationModel) new ObjectInputStream(existing).readObject()); + } catch (IOException | ClassNotFoundException e) { + log.error("Failed to load serialized app mode", e); + } + IoUtils.recursiveDelete(p); + } else { + log.error("Failed to locate serialized application model at " + serializedModel); + } + } + return null; + } + private boolean isWorkspaceDiscoveryEnabled() { return localProjectsDiscovery == null ? projectRoot != null && (test || devMode) : localProjectsDiscovery; @@ -336,34 +337,43 @@ private LocalProject loadWorkspace() throws AppModelResolverException { return project; } - private CurationResult createAppModelForJar(Path appArtifactPath) { - AppModelResolver modelResolver = getAppModelResolver(); - final ApplicationModel appModel; - ResolvedDependency appArtifact = this.appArtifact; - try { - if (appArtifact == null) { - appArtifact = ModelUtils.resolveAppArtifact(appArtifactPath); + /** + * Checks whether the project path is a JAR and if it is, creates an application model for it. + * If the project path is not a JAR, the method will return null. + * + * @param appArtifactPath application artifact path + * @return resolved application model or null + */ + private CurationResult createAppModelForJarOrNull(Path appArtifactPath) { + if (projectRoot != null + && (!Files.isDirectory(projectRoot) || projectRoot.getFileSystem().getClass().getName().contains("Zip"))) { + AppModelResolver modelResolver = getAppModelResolver(); + final ApplicationModel appModel; + ResolvedDependency appArtifact = this.appArtifact; + try { + if (appArtifact == null) { + appArtifact = ModelUtils.resolveAppArtifact(appArtifactPath); + } + modelResolver.relink(appArtifact, appArtifactPath); + //we need some way to figure out dependencies here + appModel = modelResolver.resolveManagedModel(appArtifact, List.of(), managingProject, + reloadableModules); + } catch (AppModelResolverException | IOException e) { + throw new RuntimeException("Failed to resolve initial application dependencies", e); } - modelResolver.relink(appArtifact, appArtifactPath); - //we need some way to figure out dependencies here - appModel = modelResolver.resolveManagedModel(appArtifact, List.of(), managingProject, - reloadableModules); - } catch (AppModelResolverException | IOException e) { - throw new RuntimeException("Failed to resolve initial application dependencies", e); + return new CurationResult(appModel); } - return new CurationResult(appModel); + return null; } private Path resolveCachedCpPath(LocalProject project) { - final String filePrefix = devMode ? "dev-" : (test ? "test-" : null); - return project.getOutputDir().resolve(QUARKUS).resolve(BOOTSTRAP) - .resolve(filePrefix == null ? APP_MODEL_DAT : filePrefix + APP_MODEL_DAT); - } - - private static void debug(String msg, Object... args) { - if (log.isDebugEnabled()) { - log.debug(String.format(msg, args)); + if (devMode) { + return BootstrapUtils.resolveSerializedAppModelPath(project.getOutputDir()); + } + if (test) { + return BootstrapUtils.getSerializedTestAppModelPath(project.getOutputDir()); } + return project.getOutputDir().resolve(QUARKUS).resolve(BOOTSTRAP).resolve(APP_MODEL_DAT); } public BootstrapAppModelFactory setMavenArtifactResolver(MavenArtifactResolver mavenArtifactResolver) { diff --git a/independent-projects/bootstrap/core/src/test/java/io/quarkus/bootstrap/resolver/CollectDependenciesBase.java b/independent-projects/bootstrap/core/src/test/java/io/quarkus/bootstrap/resolver/CollectDependenciesBase.java index dc86cd8b16d82..6027533372e5d 100644 --- a/independent-projects/bootstrap/core/src/test/java/io/quarkus/bootstrap/resolver/CollectDependenciesBase.java +++ b/independent-projects/bootstrap/core/src/test/java/io/quarkus/bootstrap/resolver/CollectDependenciesBase.java @@ -1,12 +1,11 @@ package io.quarkus.bootstrap.resolver; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; import java.nio.file.Path; import java.util.ArrayList; -import java.util.HashSet; +import java.util.Collection; import java.util.List; -import java.util.stream.Collectors; import org.eclipse.aether.util.artifact.JavaScopes; import org.junit.jupiter.api.BeforeEach; @@ -15,6 +14,7 @@ import io.quarkus.maven.dependency.ArtifactDependency; import io.quarkus.maven.dependency.Dependency; import io.quarkus.maven.dependency.DependencyFlags; +import io.quarkus.maven.dependency.ResolvedDependency; /** * @@ -47,10 +47,20 @@ public void testCollectedDependencies() throws Exception { expected.addAll(expectedResult); expected.addAll(deploymentDeps); } - // stripping the resolved paths - final List resolvedDeps = getTestResolver().resolveModel(root.toArtifact()).getDependencies() - .stream().map(ArtifactDependency::new).collect(Collectors.toList()); - assertEquals(new HashSet<>(expected), new HashSet<>(resolvedDeps)); + final Collection buildDeps = getTestResolver().resolveModel(root.toArtifact()).getDependencies(); + assertThat(stripResolvedPaths(buildDeps)).containsExactlyInAnyOrderElementsOf(expected); + assertBuildDependencies(buildDeps); + } + + protected void assertBuildDependencies(Collection buildDeps) { + } + + private static List stripResolvedPaths(Collection deps) { + final List result = new ArrayList<>(deps.size()); + for (var dep : deps) { + result.add(new ArtifactDependency(dep)); + } + return result; } protected BootstrapAppModelResolver getTestResolver() throws Exception { diff --git a/independent-projects/bootstrap/core/src/test/java/io/quarkus/bootstrap/resolver/test/ConditionalDependenciesDevModelTestCase.java b/independent-projects/bootstrap/core/src/test/java/io/quarkus/bootstrap/resolver/test/ConditionalDependenciesDevModelTestCase.java index 4f5a56898aa64..906df90e3efef 100644 --- a/independent-projects/bootstrap/core/src/test/java/io/quarkus/bootstrap/resolver/test/ConditionalDependenciesDevModelTestCase.java +++ b/independent-projects/bootstrap/core/src/test/java/io/quarkus/bootstrap/resolver/test/ConditionalDependenciesDevModelTestCase.java @@ -1,12 +1,20 @@ package io.quarkus.bootstrap.resolver.test; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collection; + import io.quarkus.bootstrap.app.QuarkusBootstrap; +import io.quarkus.bootstrap.model.ApplicationModelBuilder; import io.quarkus.bootstrap.resolver.BootstrapAppModelResolver; import io.quarkus.bootstrap.resolver.CollectDependenciesBase; import io.quarkus.bootstrap.resolver.TsArtifact; import io.quarkus.bootstrap.resolver.TsQuarkusExt; +import io.quarkus.bootstrap.resolver.maven.IncubatingApplicationModelResolver; import io.quarkus.bootstrap.resolver.maven.workspace.LocalProject; +import io.quarkus.maven.dependency.ArtifactCoords; import io.quarkus.maven.dependency.DependencyFlags; +import io.quarkus.maven.dependency.ResolvedDependency; public class ConditionalDependenciesDevModelTestCase extends CollectDependenciesBase { @@ -25,7 +33,11 @@ protected QuarkusBootstrap.Mode getBootstrapMode() { @Override protected void setupDependencies() { + final TsArtifact excludedLib = TsArtifact.jar("excluded-lib"); + install(excludedLib, false); + final TsQuarkusExt extA = new TsQuarkusExt("ext-a"); + extA.getRuntime().addDependency(excludedLib); install(extA, false); addCollectedDeploymentDep(extA.getDeployment()); @@ -35,6 +47,7 @@ protected void setupDependencies() { | DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT); final TsQuarkusExt extB = new TsQuarkusExt("ext-b"); + extB.setDescriptorProp(ApplicationModelBuilder.EXCLUDED_ARTIFACTS, TsArtifact.DEFAULT_GROUP_ID + ":excluded-lib"); install(extB, false); addCollectedDep(extB.getRuntime(), DependencyFlags.RUNTIME_EXTENSION_ARTIFACT); addCollectedDeploymentDep(extB.getDeployment()); @@ -97,4 +110,70 @@ protected void setupDependencies() { | DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT); addCollectedDeploymentDep(extG.getDeployment()); } + + @Override + protected void assertBuildDependencies(Collection buildDeps) { + if (!IncubatingApplicationModelResolver.isIncubatingEnabled(null)) { + return; + } + for (var d : buildDeps) { + switch (d.getArtifactId()) { + case "ext-a": + case "ext-b": + case "ext-c": + case "ext-d": + case "ext-h": + case "lib-e": + case "lib-e-build-time": + assertThat(d.getDependencies()).isEmpty(); + break; + case "ext-a-deployment": + case "ext-b-deployment": + case "ext-c-deployment": + case "ext-d-deployment": + case "ext-h-deployment": + assertThat(d.getDependencies()).containsExactlyInAnyOrder( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, + d.getArtifactId().substring(0, d.getArtifactId().length() - "-deployment".length()), + TsArtifact.DEFAULT_VERSION)); + break; + case "ext-e": + assertThat(d.getDependencies()).containsExactlyInAnyOrder( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "lib-e", TsArtifact.DEFAULT_VERSION)); + break; + case "ext-e-deployment": + assertThat(d.getDependencies()).containsExactlyInAnyOrder( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-e", TsArtifact.DEFAULT_VERSION), + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "lib-e-build-time", TsArtifact.DEFAULT_VERSION)); + break; + case "ext-f": + assertThat(d.getDependencies()).containsExactlyInAnyOrder( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-c", TsArtifact.DEFAULT_VERSION), + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-e", TsArtifact.DEFAULT_VERSION)); + break; + case "ext-f-deployment": + assertThat(d.getDependencies()).containsExactlyInAnyOrder( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-f", TsArtifact.DEFAULT_VERSION), + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-c-deployment", TsArtifact.DEFAULT_VERSION), + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-e-deployment", TsArtifact.DEFAULT_VERSION)); + break; + case "ext-g": + assertThat(d.getDependencies()).containsExactlyInAnyOrder( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-b", TsArtifact.DEFAULT_VERSION), + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "dev-only-lib", TsArtifact.DEFAULT_VERSION)); + break; + case "ext-g-deployment": + assertThat(d.getDependencies()).containsExactlyInAnyOrder( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-g", TsArtifact.DEFAULT_VERSION), + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-b-deployment", TsArtifact.DEFAULT_VERSION)); + break; + case "dev-only-lib": + assertThat(d.getDependencies()).containsExactlyInAnyOrder( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-h", TsArtifact.DEFAULT_VERSION)); + break; + default: + throw new RuntimeException("unexpected dependency " + d.toCompactCoords()); + } + } + } } diff --git a/independent-projects/bootstrap/core/src/test/java/io/quarkus/bootstrap/resolver/test/DevUiStyleConditionalDevModeDependenciesTestCase.java b/independent-projects/bootstrap/core/src/test/java/io/quarkus/bootstrap/resolver/test/DevUiStyleConditionalDevModeDependenciesTestCase.java new file mode 100644 index 0000000000000..bc4a0d7341b3f --- /dev/null +++ b/independent-projects/bootstrap/core/src/test/java/io/quarkus/bootstrap/resolver/test/DevUiStyleConditionalDevModeDependenciesTestCase.java @@ -0,0 +1,67 @@ +package io.quarkus.bootstrap.resolver.test; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collection; + +import io.quarkus.bootstrap.app.QuarkusBootstrap; +import io.quarkus.bootstrap.resolver.CollectDependenciesBase; +import io.quarkus.bootstrap.resolver.TsArtifact; +import io.quarkus.bootstrap.resolver.TsQuarkusExt; +import io.quarkus.bootstrap.resolver.maven.IncubatingApplicationModelResolver; +import io.quarkus.maven.dependency.ArtifactCoords; +import io.quarkus.maven.dependency.DependencyFlags; +import io.quarkus.maven.dependency.ResolvedDependency; + +public class DevUiStyleConditionalDevModeDependenciesTestCase extends CollectDependenciesBase { + + @Override + protected QuarkusBootstrap.Mode getBootstrapMode() { + return QuarkusBootstrap.Mode.DEV; + } + + @Override + protected void setupDependencies() { + + final TsArtifact extLibDev = TsArtifact.jar("ext-lib-dev"); + + final TsQuarkusExt extA = new TsQuarkusExt("ext-a"); + extA.setConditionalDevDeps(extLibDev); + extLibDev.addDependency(extA.getRuntime()); + + install(extA, false); + installAsDep(extA.getRuntime(), + DependencyFlags.DIRECT + | DependencyFlags.RUNTIME_EXTENSION_ARTIFACT + | DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT); + install(extLibDev, true); + addCollectedDeploymentDep(extA.getDeployment()); + } + + @Override + protected void assertBuildDependencies(Collection buildDeps) { + if (!IncubatingApplicationModelResolver.isIncubatingEnabled(null)) { + return; + } + for (var d : buildDeps) { + switch (d.getArtifactId()) { + case "ext-a": + assertThat(d.getDependencies()).containsExactlyInAnyOrder( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-lib-dev", TsArtifact.DEFAULT_VERSION)); + break; + case "ext-a-deployment": + assertThat(d.getDependencies()).containsExactlyInAnyOrder( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, + d.getArtifactId().substring(0, d.getArtifactId().length() - "-deployment".length()), + TsArtifact.DEFAULT_VERSION)); + break; + case "ext-lib-dev": + assertThat(d.getDependencies()).containsExactlyInAnyOrder( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-a", TsArtifact.DEFAULT_VERSION)); + break; + default: + throw new RuntimeException("unexpected dependency " + d.toCompactCoords()); + } + } + } +} diff --git a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/ApplicationDependencyTreeResolver.java b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/ApplicationDependencyTreeResolver.java index 252745b2202e6..641c6c5913565 100644 --- a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/ApplicationDependencyTreeResolver.java +++ b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/ApplicationDependencyTreeResolver.java @@ -234,12 +234,10 @@ private void enableConditionalDeps() { final Iterator i = unsatisfiedConditionalDeps.iterator(); while (i.hasNext()) { final ConditionalDependency cd = i.next(); - final boolean satisfied = cd.isSatisfied(); - if (!satisfied) { - continue; + if (cd.isSatisfied()) { + i.remove(); + cd.activate(); } - i.remove(); - cd.activate(); } if (totalConditionsToProcess == unsatisfiedConditionalDeps.size()) { // none of the dependencies was satisfied @@ -465,6 +463,8 @@ private void visitRuntimeDependency(DependencyNode node) { if (isWalkingFlagOn(COLLECT_TOP_EXTENSION_RUNTIME_NODES)) { dep.setFlags(DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT); } + managedDeps.add(new Dependency(extDep.info.deploymentArtifact, JavaScopes.COMPILE)); + collectConditionalDependencies(extDep); } if (isWalkingFlagOn(COLLECT_RELOADABLE_MODULES)) { if (module != null) { @@ -519,13 +519,7 @@ private ExtensionDependency getExtensionDependencyOrNull(DependencyNode node, Ar return null; } - private void visitExtensionDependency(ExtensionDependency extDep) - throws BootstrapDependencyProcessingException { - - managedDeps.add(new Dependency(extDep.info.deploymentArtifact, JavaScopes.COMPILE)); - - collectConditionalDependencies(extDep); - + private void visitExtensionDependency(ExtensionDependency extDep) { if (clearWalkingFlag(COLLECT_TOP_EXTENSION_RUNTIME_NODES)) { currentTopLevelExtension = extDep; } else if (currentTopLevelExtension != null) { diff --git a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/DependencyTreeConflictResolver.java b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/DependencyTreeConflictResolver.java new file mode 100644 index 0000000000000..999b9fb3d6cb2 --- /dev/null +++ b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/DependencyTreeConflictResolver.java @@ -0,0 +1,101 @@ +package io.quarkus.bootstrap.resolver.maven; + +import static io.quarkus.bootstrap.util.DependencyUtils.getKey; +import static io.quarkus.bootstrap.util.DependencyUtils.hasWinner; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.aether.collection.DependencyGraphTransformationContext; +import org.eclipse.aether.graph.DefaultDependencyNode; +import org.eclipse.aether.graph.Dependency; +import org.eclipse.aether.graph.DependencyNode; +import org.eclipse.aether.util.artifact.JavaScopes; +import org.eclipse.aether.util.graph.transformer.ConflictResolver; + +import io.quarkus.maven.dependency.ArtifactKey; + +/** + * Dependency tree conflict resolver. + *

    + * The idea is to have a more efficient implementation than the + * {@link org.eclipse.aether.util.graph.transformer.ConflictIdSorter#transformGraph(DependencyNode, DependencyGraphTransformationContext)} + * for the use-cases the Quarkus deployment dependency resolver is designed for. + *

    + * Specifically, this conflict resolver does not properly handle version ranges, that are not expected to be present in the + * phase it used. + */ +class DependencyTreeConflictResolver { + + /** + * Resolves dependency version conflicts in the given dependency tree. + * + * @param root the root of the dependency tree + */ + static void resolveConflicts(DependencyNode root) { + new DependencyTreeConflictResolver(root).run(); + } + + final OrderedDependencyVisitor visitor; + + private DependencyTreeConflictResolver(DependencyNode root) { + visitor = new OrderedDependencyVisitor(root); + } + + private void run() { + visitor.next();// skip the root + final Map visited = new HashMap<>(); + while (visitor.hasNext()) { + var node = visitor.next(); + if (!hasWinner(node)) { + visited.compute(getKey(node.getArtifact()), this::resolveConflict); + } + } + } + + private VisitedDependency resolveConflict(ArtifactKey key, VisitedDependency prev) { + if (prev == null) { + return new VisitedDependency(visitor); + } + prev.resolveConflict(visitor); + return prev; + } + + private static class VisitedDependency { + final DependencyNode node; + final int subtreeIndex; + + private VisitedDependency(OrderedDependencyVisitor visitor) { + this.node = visitor.getCurrent(); + this.subtreeIndex = visitor.getSubtreeIndex(); + } + + private void resolveConflict(OrderedDependencyVisitor visitor) { + var otherNode = visitor.getCurrent(); + if (subtreeIndex != visitor.getSubtreeIndex()) { + final Dependency currentDep = node.getDependency(); + final Dependency otherDep = otherNode.getDependency(); + if (!currentDep.getScope().equals(otherDep.getScope()) + && getScopePriority(currentDep.getScope()) > getScopePriority(otherDep.getScope())) { + node.setScope(otherDep.getScope()); + } + if (currentDep.isOptional() && !otherDep.isOptional()) { + node.setOptional(false); + } + } + otherNode.setChildren(List.of()); + otherNode.setData(ConflictResolver.NODE_DATA_WINNER, new DefaultDependencyNode(node.getDependency())); + } + } + + private static int getScopePriority(String scope) { + return switch (scope) { + case JavaScopes.COMPILE -> 0; + case JavaScopes.RUNTIME -> 1; + case JavaScopes.PROVIDED -> 2; + case JavaScopes.TEST -> 3; + default -> 4; + }; + } +} diff --git a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/IncubatingApplicationModelResolver.java b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/IncubatingApplicationModelResolver.java index 23fac1e5f4725..faf14cf8dc61e 100644 --- a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/IncubatingApplicationModelResolver.java +++ b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/IncubatingApplicationModelResolver.java @@ -25,12 +25,9 @@ import java.util.function.BiConsumer; import org.eclipse.aether.DefaultRepositorySystemSession; -import org.eclipse.aether.RepositoryException; -import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; import org.eclipse.aether.collection.CollectRequest; import org.eclipse.aether.collection.DependencyCollectionException; -import org.eclipse.aether.collection.DependencyGraphTransformationContext; import org.eclipse.aether.collection.DependencySelector; import org.eclipse.aether.graph.DefaultDependencyNode; import org.eclipse.aether.graph.Dependency; @@ -43,7 +40,6 @@ import org.eclipse.aether.util.artifact.JavaScopes; import org.eclipse.aether.util.graph.manager.DependencyManagerUtils; import org.eclipse.aether.util.graph.selector.ExclusionDependencySelector; -import org.eclipse.aether.util.graph.transformer.ConflictIdSorter; import org.eclipse.aether.util.graph.transformer.ConflictResolver; import org.jboss.logging.Logger; @@ -251,7 +247,7 @@ public void resolve(CollectRequest collectRtDepsRequest) throws AppModelResolver if (!runtimeModelOnly) { injectDeploymentDeps(); } - root = normalize(resolver.getSession(), root); + DependencyTreeConflictResolver.resolveConflicts(root); populateModelBuilder(root); // clear the reloadable flags @@ -464,18 +460,6 @@ private void clearReloadableFlag(ResolvedDependencyBuilder dep) { } } - private static DependencyNode normalize(RepositorySystemSession session, DependencyNode root) - throws AppModelResolverException { - final DependencyGraphTransformationContext context = new SimpleDependencyGraphTransformationContext(session); - try { - // resolves version conflicts - root = new ConflictIdSorter().transformGraph(root, context); - return session.getDependencyGraphTransformer().transformGraph(root, context); - } catch (RepositoryException e) { - throw new AppModelResolverException("Failed to resolve dependency graph conflicts", e); - } - } - /** * Resolves a project's runtime dependencies. This is the first step in the Quarkus application model resolution. * These dependencies do not include Quarkus conditional dependencies. @@ -977,6 +961,8 @@ private void collectDeploymentDeps() { + "or the artifact does not have any dependencies while at least a dependency on the runtime artifact " + info.runtimeArtifact + " is expected"); } + ensureScopeAndOptionality(deploymentNode, runtimeNode.getDependency().getScope(), + runtimeNode.getDependency().isOptional()); replaceRuntimeExtensionNodes(deploymentNode); if (!presentInTargetGraph) { @@ -1058,9 +1044,13 @@ void activate() { return; } activated = true; + final AppDep parent = conditionalDep.parent; final DependencyNode originalNode = collectDependencies(conditionalDep.node.getArtifact(), - conditionalDep.parent.ext.exclusions, - conditionalDep.parent.node.getRepositories()); + parent.ext.exclusions, + parent.node.getRepositories()); + ensureScopeAndOptionality(originalNode, parent.ext.runtimeNode.getDependency().getScope(), + parent.ext.runtimeNode.getDependency().isOptional()); + final DefaultDependencyNode rtNode = (DefaultDependencyNode) conditionalDep.node; rtNode.setRepositories(originalNode.getRepositories()); // if this node has conditional dependencies on its own, they may have been activated by this time @@ -1077,10 +1067,10 @@ void activate() { visitRuntimeDeps(); conditionalDep.setFlags( (byte) (COLLECT_DEPLOYMENT_INJECTION_POINTS | (collectReloadableModules ? COLLECT_RELOADABLE_MODULES : 0))); - if (conditionalDep.parent.resolvedDep != null) { - conditionalDep.parent.resolvedDep.addDependency(conditionalDep.resolvedDep.getArtifactCoords()); + if (parent.resolvedDep != null) { + parent.resolvedDep.addDependency(conditionalDep.resolvedDep.getArtifactCoords()); } - conditionalDep.parent.ext.runtimeNode.getChildren().add(rtNode); + parent.ext.runtimeNode.getChildren().add(rtNode); } private void visitRuntimeDeps() { @@ -1103,6 +1093,30 @@ boolean isSatisfied() { } } + /** + * Makes sure the node's dependency scope and optionality (including its children) match the expected values. + * + * @param node dependency node + * @param scope expected scope + * @param optional expected optionality + */ + private static void ensureScopeAndOptionality(DependencyNode node, String scope, boolean optional) { + var dep = node.getDependency(); + if (optional == dep.isOptional() && scope.equals(dep.getScope())) { + return; + } + var visitor = new OrderedDependencyVisitor(node); + while (visitor.hasNext()) { + dep = visitor.next().getDependency(); + if (optional != dep.isOptional()) { + visitor.getCurrent().setOptional(optional); + } + if (!scope.equals(dep.getScope())) { + visitor.getCurrent().setScope(scope); + } + } + } + private static boolean isSameKey(Artifact a1, Artifact a2) { return a2.getArtifactId().equals(a1.getArtifactId()) && a2.getGroupId().equals(a1.getGroupId()) diff --git a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitor.java b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitor.java index ae23a0f8c98c6..4c221dc96e370 100644 --- a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitor.java +++ b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitor.java @@ -13,8 +13,8 @@ */ class OrderedDependencyVisitor { - private final Deque> stack = new ArrayDeque<>(); - private List currentList; + private final Deque stack = new ArrayDeque<>(); + private DependencyList currentList; private int currentIndex = -1; private int currentDistance; private int totalOnCurrentDistance = 1; @@ -26,7 +26,7 @@ class OrderedDependencyVisitor { * @param root the root of the dependency tree */ OrderedDependencyVisitor(DependencyNode root) { - currentList = List.of(root); + currentList = new DependencyList(0, List.of(root)); } /** @@ -36,7 +36,7 @@ class OrderedDependencyVisitor { */ DependencyNode getCurrent() { ensureNonNegativeIndex(); - return currentList.get(currentIndex); + return currentList.deps.get(currentIndex); } /** @@ -62,8 +62,8 @@ private void ensureNonNegativeIndex() { */ boolean hasNext() { return !stack.isEmpty() - || currentIndex + 1 < currentList.size() - || !currentList.get(currentIndex).getChildren().isEmpty(); + || currentIndex + 1 < currentList.deps.size() + || !currentList.deps.get(currentIndex).getChildren().isEmpty(); } /** @@ -76,9 +76,9 @@ DependencyNode next() { throw new NoSuchElementException(); } if (currentIndex >= 0) { - var children = currentList.get(currentIndex).getChildren(); + var children = currentList.deps.get(currentIndex).getChildren(); if (!children.isEmpty()) { - stack.addLast(children); + stack.addLast(new DependencyList(getSubtreeIndexForChildren(), children)); totalOnNextDistance += children.size(); } if (--totalOnCurrentDistance == 0) { @@ -87,11 +87,33 @@ DependencyNode next() { totalOnNextDistance = 0; } } - if (++currentIndex == currentList.size()) { + if (++currentIndex == currentList.deps.size()) { currentList = stack.removeFirst(); currentIndex = 0; } - return currentList.get(currentIndex); + return currentList.deps.get(currentIndex); + } + + private int getSubtreeIndexForChildren() { + return currentDistance < 2 ? currentIndex + 1 : currentList.subtreeIndex; + } + + /** + * A dependency subtree index the current dependency belongs to. + * + *

    + * A dependency subtree index is an index of a direct dependency of the root of the dependency tree + * from which the dependency subtree originates. All the dependencies from a subtree that originates + * from a direct dependency of the root of the dependency tree will share the same subtree index. + * + *

    + * A dependency subtree index starts from {@code 1}. An exception is the root of the dependency tree, + * which will have the subtree index of {@code 0}. + * + * @return dependency subtree index the current dependency belongs to + */ + int getSubtreeIndex() { + return currentDistance == 0 ? 0 : (currentDistance < 2 ? currentIndex + 1 : currentList.subtreeIndex); } /** @@ -100,6 +122,21 @@ DependencyNode next() { * @param newNode dependency node that should replace the current one in the tree */ void replaceCurrent(DependencyNode newNode) { - currentList.set(currentIndex, newNode); + currentList.deps.set(currentIndex, newNode); + } + + /** + * A list of dependencies that are children of a {@link DependencyNode} + * that are associated with a dependency subtree index. + */ + private static class DependencyList { + + private final int subtreeIndex; + private final List deps; + + public DependencyList(int branchIndex, List deps) { + this.subtreeIndex = branchIndex; + this.deps = deps; + } } } diff --git a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/workspace/LocalProject.java b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/workspace/LocalProject.java index ca502db7afa0a..31da5fc9c7c2d 100644 --- a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/workspace/LocalProject.java +++ b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/workspace/LocalProject.java @@ -162,7 +162,7 @@ static Path locateCurrentProjectPom(Path path, boolean required) throws Bootstra this.modelBuildingResult = modelBuildingResult; this.workspace = workspace; if (workspace != null) { - workspace.addProject(this, rawModel.getPomFile().lastModified()); + workspace.addProject(this); } } @@ -178,7 +178,7 @@ static Path locateCurrentProjectPom(Path path, boolean required) throws Bootstra version = rawVersionIsUnresolved ? ModelUtils.resolveVersion(rawVersion, rawModel) : rawVersion; if (workspace != null) { - workspace.addProject(this, rawModel.getPomFile().lastModified()); + workspace.addProject(this); if (rawVersionIsUnresolved && version != null) { workspace.setResolvedVersion(version); } @@ -187,6 +187,10 @@ static Path locateCurrentProjectPom(Path path, boolean required) throws Bootstra } } + protected long getPomLastModified() { + return rawModel.getPomFile().lastModified(); + } + public LocalProject getLocalParent() { if (parent != null) { return parent; diff --git a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/workspace/LocalWorkspace.java b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/workspace/LocalWorkspace.java index a570d9f80455f..660a7ccb920da 100644 --- a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/workspace/LocalWorkspace.java +++ b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/workspace/LocalWorkspace.java @@ -5,6 +5,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,8 +35,8 @@ public class LocalWorkspace implements WorkspaceModelResolver, WorkspaceReader, private final WorkspaceRepository wsRepo = new WorkspaceRepository(); private ArtifactKey lastFindVersionsKey; private List lastFindVersions; - private long lastModified; - private int id = 1; + private volatile long lastModified = -1; + private volatile int id = -1; // value of the resolved version in case the raw version contains a property like ${revision} (see "Maven CI Friendly Versions") private String resolvedVersion; @@ -45,12 +46,8 @@ public class LocalWorkspace implements WorkspaceModelResolver, WorkspaceReader, private BootstrapMavenContext mvnCtx; private LocalProject currentProject; - protected void addProject(LocalProject project, long lastModified) { + protected void addProject(LocalProject project) { projects.put(project.getKey(), project); - if (lastModified > this.lastModified) { - this.lastModified = lastModified; - } - id = 31 * id + (int) (lastModified ^ (lastModified >>> 32)); } public LocalProject getProject(String groupId, String artifactId) { @@ -61,14 +58,43 @@ public LocalProject getProject(ArtifactKey key) { return projects.get(key); } + /** + * The latest last modified time of all the POMs in the workspace. + * + * @return the latest last modified time of all the POMs in the workspace + */ public long getLastModified() { + if (lastModified < 0) { + initLastModifiedAndHash(); + } return lastModified; } + /** + * This is essentially a hash code derived from each module's key. + * + * @return a hash code derived from each module's key + */ public int getId() { + if (id < 0) { + initLastModifiedAndHash(); + } return id; } + private void initLastModifiedAndHash() { + long lastModified = 0; + final int[] hashes = new int[projects.size()]; + int i = 0; + for (var project : projects.values()) { + lastModified = Math.max(project.getPomLastModified(), lastModified); + hashes[i++] = project.getKey().hashCode(); + } + Arrays.sort(hashes); + this.id = Arrays.hashCode(hashes); + this.lastModified = lastModified; + } + @Override public Model resolveRawModel(String groupId, String artifactId, String versionConstraint) throws UnresolvableModelException { diff --git a/independent-projects/bootstrap/maven-resolver/src/test/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitorTest.java b/independent-projects/bootstrap/maven-resolver/src/test/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitorTest.java index b77d7ef1d1932..300df5904403a 100644 --- a/independent-projects/bootstrap/maven-resolver/src/test/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitorTest.java +++ b/independent-projects/bootstrap/maven-resolver/src/test/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitorTest.java @@ -59,77 +59,90 @@ public void main() { assertThat(visitor.next()).isSameAs(root); assertThat(visitor.getCurrent()).isSameAs(root); assertThat(visitor.getCurrentDistance()).isEqualTo(0); + assertThat(visitor.getSubtreeIndex()).isEqualTo(0); assertThat(visitor.hasNext()).isTrue(); // distance 1, colors assertThat(visitor.next()).isSameAs(colors); assertThat(visitor.getCurrent()).isSameAs(colors); assertThat(visitor.getCurrentDistance()).isEqualTo(1); + assertThat(visitor.getSubtreeIndex()).isEqualTo(1); assertThat(visitor.hasNext()).isTrue(); // distance 1, pets assertThat(visitor.next()).isSameAs(pets); assertThat(visitor.getCurrent()).isSameAs(pets); assertThat(visitor.getCurrentDistance()).isEqualTo(1); + assertThat(visitor.getSubtreeIndex()).isEqualTo(2); assertThat(visitor.hasNext()).isTrue(); // distance 1, trees assertThat(visitor.next()).isSameAs(trees); assertThat(visitor.getCurrent()).isSameAs(trees); assertThat(visitor.getCurrentDistance()).isEqualTo(1); + assertThat(visitor.getSubtreeIndex()).isEqualTo(3); assertThat(visitor.hasNext()).isTrue(); // distance 2, colors, red assertThat(visitor.next()).isSameAs(red); assertThat(visitor.getCurrent()).isSameAs(red); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(1); assertThat(visitor.hasNext()).isTrue(); // distance 2, colors, green assertThat(visitor.next()).isSameAs(green); assertThat(visitor.getCurrent()).isSameAs(green); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(1); assertThat(visitor.hasNext()).isTrue(); // distance 2, colors, blue assertThat(visitor.next()).isSameAs(blue); assertThat(visitor.getCurrent()).isSameAs(blue); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(1); assertThat(visitor.hasNext()).isTrue(); // distance 2, pets, dog assertThat(visitor.next()).isSameAs(dog); assertThat(visitor.getCurrent()).isSameAs(dog); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(2); assertThat(visitor.hasNext()).isTrue(); // distance 2, pets, cat assertThat(visitor.next()).isSameAs(cat); assertThat(visitor.getCurrent()).isSameAs(cat); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(2); assertThat(visitor.hasNext()).isTrue(); // distance 2, trees, pine assertThat(visitor.next()).isSameAs(pine); assertThat(visitor.getCurrent()).isSameAs(pine); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(3); assertThat(visitor.hasNext()).isTrue(); // replace the current node visitor.replaceCurrent(oak); assertThat(visitor.getCurrent()).isSameAs(oak); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(3); assertThat(visitor.hasNext()).isTrue(); // distance 3, pets, dog, puppy assertThat(visitor.next()).isSameAs(puppy); assertThat(visitor.getCurrent()).isSameAs(puppy); assertThat(visitor.getCurrentDistance()).isEqualTo(3); + assertThat(visitor.getSubtreeIndex()).isEqualTo(2); assertThat(visitor.hasNext()).isTrue(); // distance 3, trees, oak, acorn assertThat(visitor.next()).isSameAs(acorn); assertThat(visitor.getCurrent()).isSameAs(acorn); assertThat(visitor.getCurrentDistance()).isEqualTo(3); + assertThat(visitor.getSubtreeIndex()).isEqualTo(3); assertThat(visitor.hasNext()).isFalse(); } diff --git a/independent-projects/bootstrap/pom.xml b/independent-projects/bootstrap/pom.xml index 392291f6152e3..495a5a5d39e30 100644 --- a/independent-projects/bootstrap/pom.xml +++ b/independent-projects/bootstrap/pom.xml @@ -38,7 +38,7 @@ 1.37 - 3.26.3 + 3.27.0 0.9.5 3.6.1.Final 5.10.5 @@ -57,11 +57,11 @@ 1.17.1 2.18.0 3.17.0 - 33.3.1-jre + 33.4.0-jre 1.0.1 2.8 1.2.6 - 3.1.0.Final + 3.1.1.Final 1.1.0.Final 2.0.6 23.1.0 @@ -70,7 +70,7 @@ 1.26 2.0 3.5.1 - 2.8.0 + 2.9.0 1.5.2 8.9 0.0.10 diff --git a/independent-projects/enforcer-rules/pom.xml b/independent-projects/enforcer-rules/pom.xml index 488f1e42a002c..ef71299114d21 100644 --- a/independent-projects/enforcer-rules/pom.xml +++ b/independent-projects/enforcer-rules/pom.xml @@ -34,7 +34,7 @@ 3.0.0-M3 - 3.8.1 + 3.9.0 3.9.9 - 3.26.3 + 3.27.0 2.18.2 4.1.0 5.10.5 diff --git a/integration-tests/container-image/maven-invoker-way/src/it/container-build-docker/pom.xml b/integration-tests/container-image/maven-invoker-way/src/it/container-build-docker/pom.xml index 6634fa060264c..3b83eb3a6f7d1 100644 --- a/integration-tests/container-image/maven-invoker-way/src/it/container-build-docker/pom.xml +++ b/integration-tests/container-image/maven-invoker-way/src/it/container-build-docker/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/container-image/maven-invoker-way/src/it/container-build-jib-appcds/pom.xml b/integration-tests/container-image/maven-invoker-way/src/it/container-build-jib-appcds/pom.xml index 59510e3af52ed..95ad7a9106bae 100644 --- a/integration-tests/container-image/maven-invoker-way/src/it/container-build-jib-appcds/pom.xml +++ b/integration-tests/container-image/maven-invoker-way/src/it/container-build-jib-appcds/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/container-image/maven-invoker-way/src/it/container-build-jib-inherit/pom.xml b/integration-tests/container-image/maven-invoker-way/src/it/container-build-jib-inherit/pom.xml index 9be34487d77a4..46c5dc85531d3 100644 --- a/integration-tests/container-image/maven-invoker-way/src/it/container-build-jib-inherit/pom.xml +++ b/integration-tests/container-image/maven-invoker-way/src/it/container-build-jib-inherit/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/container-image/maven-invoker-way/src/it/container-build-jib/pom.xml b/integration-tests/container-image/maven-invoker-way/src/it/container-build-jib/pom.xml index fc80512091681..7aa61521beaba 100644 --- a/integration-tests/container-image/maven-invoker-way/src/it/container-build-jib/pom.xml +++ b/integration-tests/container-image/maven-invoker-way/src/it/container-build-jib/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/container-image/maven-invoker-way/src/it/container-build-multiple-tags-docker/pom.xml b/integration-tests/container-image/maven-invoker-way/src/it/container-build-multiple-tags-docker/pom.xml index 6224987c1d462..be38e3eec1f5f 100644 --- a/integration-tests/container-image/maven-invoker-way/src/it/container-build-multiple-tags-docker/pom.xml +++ b/integration-tests/container-image/maven-invoker-way/src/it/container-build-multiple-tags-docker/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/container-image/maven-invoker-way/src/it/container-build-multiple-tags-jib/pom.xml b/integration-tests/container-image/maven-invoker-way/src/it/container-build-multiple-tags-jib/pom.xml index a18ef6c4db78c..372b14d6dc9f4 100644 --- a/integration-tests/container-image/maven-invoker-way/src/it/container-build-multiple-tags-jib/pom.xml +++ b/integration-tests/container-image/maven-invoker-way/src/it/container-build-multiple-tags-jib/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/container-image/maven-invoker-way/src/it/container-image-push/pom.xml b/integration-tests/container-image/maven-invoker-way/src/it/container-image-push/pom.xml index 96e0b6696b1ef..42f36cb5c5ea9 100644 --- a/integration-tests/container-image/maven-invoker-way/src/it/container-image-push/pom.xml +++ b/integration-tests/container-image/maven-invoker-way/src/it/container-image-push/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/hibernate-orm-compatibility-5.6/database-generator/pom.xml b/integration-tests/hibernate-orm-compatibility-5.6/database-generator/pom.xml index a4310bc090b71..bfca054cdef80 100644 --- a/integration-tests/hibernate-orm-compatibility-5.6/database-generator/pom.xml +++ b/integration-tests/hibernate-orm-compatibility-5.6/database-generator/pom.xml @@ -15,7 +15,7 @@ io.quarkus.platform 2.16.3.Final true - 3.5.0 + 3.5.2 diff --git a/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/DevServicesContextSpy.java b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/DevServicesContextSpy.java deleted file mode 100644 index c2c08bcff10be..0000000000000 --- a/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/DevServicesContextSpy.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.quarkus.it.hibernate.search.orm.elasticsearch.devservices; - -import java.util.Collections; -import java.util.Map; - -import io.quarkus.test.common.DevServicesContext; -import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; - -public class DevServicesContextSpy implements QuarkusTestResourceLifecycleManager, DevServicesContext.ContextAware { - - DevServicesContext devServicesContext; - - @Override - public void setIntegrationTestContext(DevServicesContext context) { - this.devServicesContext = context; - } - - @Override - public Map start() { - return Collections.emptyMap(); - } - - @Override - public void inject(TestInjector testInjector) { - testInjector.injectIntoFields(devServicesContext, f -> f.getType().isAssignableFrom(DevServicesContext.class)); - } - - @Override - public void stop() { - - } -} diff --git a/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledExplicitlyTest.java b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledExplicitlyTest.java index 83f6c783fbbaa..8e2fd63e27479 100644 --- a/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledExplicitlyTest.java +++ b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledExplicitlyTest.java @@ -4,7 +4,6 @@ import static org.hamcrest.Matchers.is; import java.util.HashMap; -import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -49,12 +48,6 @@ public String getConfigProfile() { // In this test, we do NOT set quarkus.hibernate-search-orm.elasticsearch.hosts. return "someotherprofile"; } - - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledImplicitlyTest.java b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledImplicitlyTest.java index 768ead7d20475..efe94e41559fc 100644 --- a/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledImplicitlyTest.java +++ b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledImplicitlyTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; -import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -47,12 +46,6 @@ public String getConfigProfile() { // In this test, we DO set quarkus.hibernate-search-orm.elasticsearch.hosts (see above). return "someotherprofile"; } - - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesEnabledImplicitlyTest.java b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesEnabledImplicitlyTest.java index 2667f733e5857..95d1826040cb8 100644 --- a/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesEnabledImplicitlyTest.java +++ b/integration-tests/hibernate-search-orm-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/orm/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesEnabledImplicitlyTest.java @@ -3,8 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; -import java.util.List; - import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; @@ -27,12 +25,6 @@ public String getConfigProfile() { // In this test, we do NOT set quarkus.hibernate-search-orm.elasticsearch.hosts. return "someotherprofile"; } - - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/DevServicesContextSpy.java b/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/DevServicesContextSpy.java deleted file mode 100644 index c4d78dd73aaa4..0000000000000 --- a/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/DevServicesContextSpy.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.quarkus.it.hibernate.search.orm.opensearch.devservices; - -import java.util.Collections; -import java.util.Map; - -import io.quarkus.test.common.DevServicesContext; -import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; - -public class DevServicesContextSpy implements QuarkusTestResourceLifecycleManager, DevServicesContext.ContextAware { - - DevServicesContext devServicesContext; - - @Override - public void setIntegrationTestContext(DevServicesContext context) { - this.devServicesContext = context; - } - - @Override - public Map start() { - return Collections.emptyMap(); - } - - @Override - public void inject(TestInjector testInjector) { - testInjector.injectIntoFields(devServicesContext, f -> f.getType().isAssignableFrom(DevServicesContext.class)); - } - - @Override - public void stop() { - - } -} diff --git a/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesConfiguredExplicitlyTest.java b/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesConfiguredExplicitlyTest.java index 12b7385638b0e..7185a0f296cc2 100644 --- a/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesConfiguredExplicitlyTest.java +++ b/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesConfiguredExplicitlyTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; -import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -40,11 +39,6 @@ public String getConfigProfile() { return "someotherprofile"; } - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledExplicitlyTest.java b/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledExplicitlyTest.java index 05e418748ab6c..2aadb26c771ad 100644 --- a/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledExplicitlyTest.java +++ b/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledExplicitlyTest.java @@ -4,7 +4,6 @@ import static org.hamcrest.Matchers.is; import java.util.HashMap; -import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -49,12 +48,6 @@ public String getConfigProfile() { // In this test, we do NOT set quarkus.hibernate-search-orm.elasticsearch.hosts. return "someotherprofile"; } - - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledImplicitlyTest.java b/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledImplicitlyTest.java index ac9a8b06bd270..80163bbff07e1 100644 --- a/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledImplicitlyTest.java +++ b/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledImplicitlyTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; -import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -47,12 +46,6 @@ public String getConfigProfile() { // In this test, we DO set quarkus.hibernate-search-orm.elasticsearch.hosts (see above). return "someotherprofile"; } - - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesEnabledImplicitlyTest.java b/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesEnabledImplicitlyTest.java index 1287d5823c65f..4ec25d37e9a7f 100644 --- a/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesEnabledImplicitlyTest.java +++ b/integration-tests/hibernate-search-orm-opensearch/src/test/java/io/quarkus/it/hibernate/search/orm/opensearch/devservices/HibernateSearchOpenSearchDevServicesEnabledImplicitlyTest.java @@ -3,8 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; -import java.util.List; - import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; @@ -28,12 +26,6 @@ public String getConfigProfile() { // In this test, we do NOT set quarkus.hibernate-search-orm.elasticsearch.hosts. return "someotherprofile"; } - - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/DevServicesContextSpy.java b/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/DevServicesContextSpy.java deleted file mode 100644 index 7ccf9233bdc44..0000000000000 --- a/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/DevServicesContextSpy.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.quarkus.it.hibernate.search.standalone.elasticsearch.devservices; - -import java.util.Collections; -import java.util.Map; - -import io.quarkus.test.common.DevServicesContext; -import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; - -public class DevServicesContextSpy implements QuarkusTestResourceLifecycleManager, DevServicesContext.ContextAware { - - DevServicesContext devServicesContext; - - @Override - public void setIntegrationTestContext(DevServicesContext context) { - this.devServicesContext = context; - } - - @Override - public Map start() { - return Collections.emptyMap(); - } - - @Override - public void inject(TestInjector testInjector) { - testInjector.injectIntoFields(devServicesContext, f -> f.getType().isAssignableFrom(DevServicesContext.class)); - } - - @Override - public void stop() { - - } -} diff --git a/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledExplicitlyTest.java b/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledExplicitlyTest.java index 5a32300a279d2..f824fc3f736fe 100644 --- a/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledExplicitlyTest.java +++ b/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledExplicitlyTest.java @@ -4,7 +4,6 @@ import static org.hamcrest.Matchers.is; import java.util.HashMap; -import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -50,11 +49,6 @@ public String getConfigProfile() { return "someotherprofile"; } - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledImplicitlyTest.java b/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledImplicitlyTest.java index a5c43c6a6f9cf..6ef6ef2bd17e2 100644 --- a/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledImplicitlyTest.java +++ b/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesDisabledImplicitlyTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; -import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -48,11 +47,6 @@ public String getConfigProfile() { return "someotherprofile"; } - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesEnabledImplicitlyTest.java b/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesEnabledImplicitlyTest.java index e1bbaa08dcd27..bea2b0dbe4b1b 100644 --- a/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesEnabledImplicitlyTest.java +++ b/integration-tests/hibernate-search-standalone-elasticsearch/src/test/java/io/quarkus/it/hibernate/search/standalone/elasticsearch/devservices/HibernateSearchElasticsearchDevServicesEnabledImplicitlyTest.java @@ -3,8 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; -import java.util.List; - import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; @@ -28,11 +26,6 @@ public String getConfigProfile() { return "someotherprofile"; } - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/DevServicesContextSpy.java b/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/DevServicesContextSpy.java deleted file mode 100644 index 9f3ddf585fbb8..0000000000000 --- a/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/DevServicesContextSpy.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.quarkus.it.hibernate.search.standalone.opensearch.devservices; - -import java.util.Collections; -import java.util.Map; - -import io.quarkus.test.common.DevServicesContext; -import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; - -public class DevServicesContextSpy implements QuarkusTestResourceLifecycleManager, DevServicesContext.ContextAware { - - DevServicesContext devServicesContext; - - @Override - public void setIntegrationTestContext(DevServicesContext context) { - this.devServicesContext = context; - } - - @Override - public Map start() { - return Collections.emptyMap(); - } - - @Override - public void inject(TestInjector testInjector) { - testInjector.injectIntoFields(devServicesContext, f -> f.getType().isAssignableFrom(DevServicesContext.class)); - } - - @Override - public void stop() { - - } -} diff --git a/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledExplicitlyTest.java b/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledExplicitlyTest.java index 024a463b9b38c..a794bb91c0cdc 100644 --- a/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledExplicitlyTest.java +++ b/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledExplicitlyTest.java @@ -4,7 +4,6 @@ import static org.hamcrest.Matchers.is; import java.util.HashMap; -import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -49,12 +48,6 @@ public String getConfigProfile() { // In this test, we do NOT set quarkus.hibernate-search-standalone.elasticsearch.hosts. return "someotherprofile"; } - - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledImplicitlyTest.java b/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledImplicitlyTest.java index c91b2b245d6ad..246a745b10f86 100644 --- a/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledImplicitlyTest.java +++ b/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/HibernateSearchOpenSearchDevServicesDisabledImplicitlyTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; -import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -48,11 +47,6 @@ public String getConfigProfile() { return "someotherprofile"; } - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/HibernateSearchOpenSearchDevServicesEnabledImplicitlyTest.java b/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/HibernateSearchOpenSearchDevServicesEnabledImplicitlyTest.java index b3759a207d67e..0e7a317ba0206 100644 --- a/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/HibernateSearchOpenSearchDevServicesEnabledImplicitlyTest.java +++ b/integration-tests/hibernate-search-standalone-opensearch/src/test/java/io/quarkus/it/hibernate/search/standalone/opensearch/devservices/HibernateSearchOpenSearchDevServicesEnabledImplicitlyTest.java @@ -3,8 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; -import java.util.List; - import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; @@ -27,12 +25,6 @@ public String getConfigProfile() { // In this test, we do NOT set quarkus.hibernate-search-standalone.elasticsearch.hosts. return "someotherprofile"; } - - @Override - public List testResources() { - // Enables injection of DevServicesContext - return List.of(new TestResourceEntry(DevServicesContextSpy.class)); - } } DevServicesContext context; diff --git a/integration-tests/istio/maven-invoker-way/src/it/xds-grpc/pom.xml b/integration-tests/istio/maven-invoker-way/src/it/xds-grpc/pom.xml index 6d250a376993a..645068923f8c2 100644 --- a/integration-tests/istio/maven-invoker-way/src/it/xds-grpc/pom.xml +++ b/integration-tests/istio/maven-invoker-way/src/it/xds-grpc/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/jfr-blocking/pom.xml b/integration-tests/jfr-blocking/pom.xml index b032c6feaf2a9..9e9009997ebf6 100644 --- a/integration-tests/jfr-blocking/pom.xml +++ b/integration-tests/jfr-blocking/pom.xml @@ -8,8 +8,8 @@ quarkus-integration-tests-parent 999-SNAPSHOT - jfr-blocking-integration-tests - Quarkus - Integration Tests - Jfr Blocking + quarkus-integration-test-jfr-blocking + Quarkus - Integration Tests - JFR Blocking true diff --git a/integration-tests/jfr-opentelemetry/pom.xml b/integration-tests/jfr-opentelemetry/pom.xml index c319f162d875b..8740c2cc352a8 100644 --- a/integration-tests/jfr-opentelemetry/pom.xml +++ b/integration-tests/jfr-opentelemetry/pom.xml @@ -7,8 +7,8 @@ quarkus-integration-tests-parent 999-SNAPSHOT - jf-opentelemetry-integration-tests - Quarkus - Integration Tests - Jfr OpenTelemetry + quarkus-integration-test-jfr-opentelemetry + Quarkus - Integration Tests - JFR OpenTelemetry true diff --git a/integration-tests/jfr-reactive/pom.xml b/integration-tests/jfr-reactive/pom.xml index a8b55e033febb..2b17ec29178a2 100644 --- a/integration-tests/jfr-reactive/pom.xml +++ b/integration-tests/jfr-reactive/pom.xml @@ -8,8 +8,8 @@ quarkus-integration-tests-parent 999-SNAPSHOT - jfr-reactive-integration-tests - Quarkus - Integration Tests - Jfr Reactive + quarkus-integration-test-jfr-reactive + Quarkus - Integration Tests - JFR Reactive true diff --git a/integration-tests/jfr-reactive/src/main/java/io/quarkus/jfr/it/AppResource.java b/integration-tests/jfr-reactive/src/main/java/io/quarkus/jfr/it/AppResource.java index 0d69b1780272c..3045bbc4dd113 100644 --- a/integration-tests/jfr-reactive/src/main/java/io/quarkus/jfr/it/AppResource.java +++ b/integration-tests/jfr-reactive/src/main/java/io/quarkus/jfr/it/AppResource.java @@ -2,8 +2,11 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.MediaType; import io.quarkus.jfr.runtime.IdProducer; import io.smallrye.mutiny.Uni; @@ -31,6 +34,13 @@ public IdResponse blocking() { return new IdResponse(idProducer.getTraceId(), idProducer.getSpanId()); } + @POST + @Path("consuming") + @Consumes(MediaType.APPLICATION_JSON) + public IdResponse consuming(IdResponse idResponse) { + return new IdResponse(idProducer.getTraceId(), idProducer.getSpanId()); + } + @GET @Path("error") public void error() { diff --git a/integration-tests/jfr-reactive/src/main/java/io/quarkus/jfr/it/JfrResource.java b/integration-tests/jfr-reactive/src/main/java/io/quarkus/jfr/it/JfrResource.java index 4749eb3e42ce9..76c3a9c949310 100644 --- a/integration-tests/jfr-reactive/src/main/java/io/quarkus/jfr/it/JfrResource.java +++ b/integration-tests/jfr-reactive/src/main/java/io/quarkus/jfr/it/JfrResource.java @@ -6,9 +6,11 @@ import java.text.ParseException; import java.util.List; import java.util.Optional; +import java.util.function.Predicate; import jakarta.enterprise.context.ApplicationScoped; import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; @@ -50,9 +52,22 @@ public void stopJfr(@PathParam("name") String name) throws IOException { } @GET - @Path("check/{name}/{traceId}") + @Path("check/{name}/traceId/{traceId}") @Produces(MediaType.APPLICATION_JSON) - public JfrRestEventResponse check(@PathParam("name") String name, @PathParam("traceId") String traceId) throws IOException { + public JfrRestEventResponse checkForTraceId(@PathParam("name") String name, @PathParam("traceId") String traceId) + throws IOException { + return doCheck(name, (e) -> e.hasField("traceId") && e.getString("traceId").equals(traceId)); + } + + @GET + @Path("check/{name}/uri") + @Produces(MediaType.APPLICATION_JSON) + public JfrRestEventResponse checkForPath(@PathParam("name") String name, @HeaderParam("uri") String uri) + throws IOException { + return doCheck(name, (e) -> e.hasField("uri") && e.getString("uri").equals(uri)); + } + + private JfrRestEventResponse doCheck(String name, Predicate predicate) throws IOException { java.nio.file.Path dumpFile = Files.createTempFile("dump", "jfr"); Recording recording = getRecording(name); recording.dump(dumpFile); @@ -73,7 +88,7 @@ public JfrRestEventResponse check(@PathParam("name") String name, @PathParam("tr Log.debug(e); } } - if (e.hasField("traceId") && e.getString("traceId").equals(traceId)) { + if (predicate.test(e)) { if (RestPeriodEvent.class.getAnnotation(Name.class).value().equals(e.getEventType().getName())) { periodEvent = e; } else if (RestStartEvent.class.getAnnotation(Name.class).value().equals(e.getEventType().getName())) { diff --git a/integration-tests/jfr-reactive/src/test/java/io/quarkus/jfr/it/JfrTest.java b/integration-tests/jfr-reactive/src/test/java/io/quarkus/jfr/it/JfrTest.java index 81d7bb23b6d23..1b2999974dd94 100644 --- a/integration-tests/jfr-reactive/src/test/java/io/quarkus/jfr/it/JfrTest.java +++ b/integration-tests/jfr-reactive/src/test/java/io/quarkus/jfr/it/JfrTest.java @@ -16,7 +16,6 @@ public class JfrTest { private static final String CLIENT = "127.0.0.1:\\d{1,5}"; - private static final String HTTP_METHOD = "GET"; private static final String RESOURCE_CLASS = "io.quarkus.jfr.it.AppResource"; @Test @@ -46,14 +45,14 @@ public void blockingTest() { final String resourceMethod = "blocking"; ValidatableResponse validatableResponse = given() - .when().get("/jfr/check/" + jfrName + "/" + response.traceId) + .when().get("/jfr/check/" + jfrName + "/traceId/" + response.traceId) .then() .statusCode(200) .body("start", notNullValue()) .body("start.uri", is(url)) .body("start.traceId", is(response.traceId)) .body("start.spanId", is(response.spanId)) - .body("start.httpMethod", is(HTTP_METHOD)) + .body("start.httpMethod", is("GET")) .body("start.resourceClass", is(RESOURCE_CLASS)) .body("start.resourceMethod", is(resourceMethod)) .body("start.client", matchesRegex(CLIENT)) @@ -61,7 +60,7 @@ public void blockingTest() { .body("end.uri", is(url)) .body("end.traceId", is(response.traceId)) .body("end.spanId", is(response.spanId)) - .body("end.httpMethod", is(HTTP_METHOD)) + .body("end.httpMethod", is("GET")) .body("end.resourceClass", is(RESOURCE_CLASS)) .body("end.resourceMethod", is(resourceMethod)) .body("end.client", matchesRegex(CLIENT)) @@ -69,7 +68,7 @@ public void blockingTest() { .body("period.uri", is(url)) .body("period.traceId", is(response.traceId)) .body("period.spanId", is(response.spanId)) - .body("period.httpMethod", is(HTTP_METHOD)) + .body("period.httpMethod", is("GET")) .body("period.resourceClass", is(RESOURCE_CLASS)) .body("period.resourceMethod", is(resourceMethod)) .body("period.client", matchesRegex(CLIENT)); @@ -101,14 +100,14 @@ public void reactiveTest() { final String resourceMethod = "reactive"; ValidatableResponse validatableResponse = given() - .when().get("/jfr/check/" + jfrName + "/" + response.traceId) + .when().get("/jfr/check/" + jfrName + "/traceId/" + response.traceId) .then() .statusCode(200) .body("start", notNullValue()) .body("start.uri", is(url)) .body("start.traceId", is(response.traceId)) .body("start.spanId", is(response.spanId)) - .body("start.httpMethod", is(HTTP_METHOD)) + .body("start.httpMethod", is("GET")) .body("start.resourceClass", is(RESOURCE_CLASS)) .body("start.resourceMethod", is(resourceMethod)) .body("start.client", matchesRegex(CLIENT)) @@ -116,7 +115,7 @@ public void reactiveTest() { .body("end.uri", is(url)) .body("end.traceId", is(response.traceId)) .body("end.spanId", is(response.spanId)) - .body("end.httpMethod", is(HTTP_METHOD)) + .body("end.httpMethod", is("GET")) .body("end.resourceClass", is(RESOURCE_CLASS)) .body("end.resourceMethod", is(resourceMethod)) .body("end.client", matchesRegex(CLIENT)) @@ -124,7 +123,7 @@ public void reactiveTest() { .body("period.uri", is(url)) .body("period.traceId", is(response.traceId)) .body("period.spanId", is(response.spanId)) - .body("period.httpMethod", is(HTTP_METHOD)) + .body("period.httpMethod", is("GET")) .body("period.resourceClass", is(RESOURCE_CLASS)) .body("period.resourceMethod", is(resourceMethod)) .body("period.client", matchesRegex(CLIENT)); @@ -156,14 +155,14 @@ public void errorTest() { final String resourceMethod = "error"; ValidatableResponse validatableResponse = given() - .when().get("/jfr/check/" + jfrName + "/" + traceId) + .when().get("/jfr/check/" + jfrName + "/traceId/" + traceId) .then() .statusCode(200) .body("start", notNullValue()) .body("start.uri", is(url)) .body("start.traceId", is(traceId)) .body("start.spanId", nullValue()) - .body("start.httpMethod", is(HTTP_METHOD)) + .body("start.httpMethod", is("GET")) .body("start.resourceClass", is(RESOURCE_CLASS)) .body("start.resourceMethod", is(resourceMethod)) .body("start.client", matchesRegex(CLIENT)) @@ -171,7 +170,7 @@ public void errorTest() { .body("end.uri", is(url)) .body("end.traceId", is(traceId)) .body("end.spanId", is(nullValue())) - .body("end.httpMethod", is(HTTP_METHOD)) + .body("end.httpMethod", is("GET")) .body("end.resourceClass", is(RESOURCE_CLASS)) .body("end.resourceMethod", is(resourceMethod)) .body("end.client", matchesRegex(CLIENT)) @@ -179,7 +178,7 @@ public void errorTest() { .body("period.uri", is(url)) .body("period.traceId", is(traceId)) .body("period.spanId", is(nullValue())) - .body("period.httpMethod", is(HTTP_METHOD)) + .body("period.httpMethod", is("GET")) .body("period.resourceClass", is(RESOURCE_CLASS)) .body("period.resourceMethod", is(resourceMethod)) .body("period.client", matchesRegex(CLIENT)); @@ -214,4 +213,111 @@ public void nonExistURL() { Assertions.assertEquals(0, count); } + + @Test + public void invalidHttpMethod() { + String jfrName = "invalidHttpMethodTest"; + + given() + .when().get("/jfr/start/" + jfrName) + .then() + .statusCode(204); + + String url = "/app/blocking"; + given() + .when() + .post(url) + .then() + .statusCode(405); + + given() + .when().get("/jfr/stop/" + jfrName) + .then() + .statusCode(204); + + given() + .header("uri", url) + .when().get("/jfr/check/" + jfrName + "/uri") + .then() + .statusCode(200) + .body("start", notNullValue()) + .body("start.uri", is(url)) + .body("start.traceId", notNullValue()) + .body("start.spanId", nullValue()) + .body("start.httpMethod", is("POST")) + .body("start.resourceClass", nullValue()) + .body("start.resourceMethod", nullValue()) + .body("start.client", matchesRegex(CLIENT)) + .body("end", notNullValue()) + .body("end.uri", is(url)) + .body("end.traceId", notNullValue()) + .body("end.spanId", nullValue()) + .body("end.httpMethod", is("POST")) + .body("end.resourceClass", nullValue()) + .body("end.resourceMethod", nullValue()) + .body("end.client", matchesRegex(CLIENT)) + .body("period", notNullValue()) + .body("period.uri", is(url)) + .body("period.traceId", notNullValue()) + .body("period.spanId", nullValue()) + .body("period.httpMethod", is("POST")) + .body("period.resourceClass", nullValue()) + .body("period.resourceMethod", nullValue()) + .body("period.client", matchesRegex(CLIENT)); + } + + @Test + public void unhandledContentType() { + String jfrName = "unhandledContentType"; + + given() + .when().get("/jfr/start/" + jfrName) + .then() + .statusCode(204); + + String url = "/app/consuming"; + given() + .contentType("text/plain") + .body("whatever") + .when() + .post(url) + .then() + .statusCode(415); + + given() + .when().get("/jfr/stop/" + jfrName) + .then() + .statusCode(204); + + given() + .header("uri", url) + .when().get("/jfr/check/" + jfrName + "/uri") + .then() + .statusCode(200) + .body("start", notNullValue()) + .body("start.uri", is(url)) + .body("start.traceId", notNullValue()) + .body("start.spanId", nullValue()) + .body("start.httpMethod", is("POST")) + .body("start.resourceClass", nullValue()) + .body("start.resourceMethod", nullValue()) + .body("start.client", matchesRegex(CLIENT)) + .body("end", notNullValue()) + .body("end.uri", is(url)) + .body("end.traceId", notNullValue()) + .body("end.spanId", nullValue()) + .body("end.httpMethod", is("POST")) + .body("end.resourceClass", nullValue()) + .body("end.resourceMethod", nullValue()) + .body("end.client", matchesRegex(CLIENT)) + .body("period", notNullValue()) + .body("period.uri", is(url)) + .body("period.traceId", notNullValue()) + .body("period.spanId", nullValue()) + .body("period.httpMethod", is("POST")) + .body("period.resourceClass", nullValue()) + .body("period.resourceMethod", nullValue()) + .body("period.client", matchesRegex(CLIENT)); + } + } diff --git a/integration-tests/kotlin/disable-native-profile b/integration-tests/kotlin-maven-invoker/disable-native-profile similarity index 100% rename from integration-tests/kotlin/disable-native-profile rename to integration-tests/kotlin-maven-invoker/disable-native-profile diff --git a/integration-tests/kotlin-maven-invoker/pom.xml b/integration-tests/kotlin-maven-invoker/pom.xml new file mode 100644 index 0000000000000..6f79c2f1b039b --- /dev/null +++ b/integration-tests/kotlin-maven-invoker/pom.xml @@ -0,0 +1,128 @@ + + + 4.0.0 + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-integration-test-kotlin-invoker + Quarkus - Integration Tests - Kotlin - Invoker + Kotlin integration tests that need to use the Maven invoker + + + + io.quarkus + quarkus-kotlin-deployment + + + io.quarkus + quarkus-bootstrap-maven-resolver + test + + + io.quarkus + quarkus-project-core-extension-codestarts + test + + + io.quarkus + quarkus-test-maven + test + + + org.jetbrains.kotlin + kotlin-test + ${kotlin.version} + test + + + org.apache.maven + maven-model + test + + + org.apache.commons + commons-lang3 + test + + + + + + + src/test/resources + true + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + ${project.version} + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + compile + compile + + compile + + + + test-compile + test-compile + + test-compile + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + testCompile + test-compile + + testCompile + + + + + + + + + basic-test-suite + + + basicTests + + + + true + + + + diff --git a/integration-tests/kotlin/src/test/java/io/quarkus/kotlin/maven/it/KotlinCreateMavenProjectIT.java b/integration-tests/kotlin-maven-invoker/src/test/java/io/quarkus/kotlin/maven/it/KotlinCreateMavenProjectIT.java similarity index 100% rename from integration-tests/kotlin/src/test/java/io/quarkus/kotlin/maven/it/KotlinCreateMavenProjectIT.java rename to integration-tests/kotlin-maven-invoker/src/test/java/io/quarkus/kotlin/maven/it/KotlinCreateMavenProjectIT.java diff --git a/integration-tests/kotlin/src/test/java/io/quarkus/kotlin/maven/it/KotlinDevModeIT.java b/integration-tests/kotlin-maven-invoker/src/test/java/io/quarkus/kotlin/maven/it/KotlinDevModeIT.java similarity index 100% rename from integration-tests/kotlin/src/test/java/io/quarkus/kotlin/maven/it/KotlinDevModeIT.java rename to integration-tests/kotlin-maven-invoker/src/test/java/io/quarkus/kotlin/maven/it/KotlinDevModeIT.java diff --git a/integration-tests/kotlin/src/test/java/io/quarkus/kotlin/maven/it/KotlinRemoteDevModeIT.java b/integration-tests/kotlin-maven-invoker/src/test/java/io/quarkus/kotlin/maven/it/KotlinRemoteDevModeIT.java similarity index 100% rename from integration-tests/kotlin/src/test/java/io/quarkus/kotlin/maven/it/KotlinRemoteDevModeIT.java rename to integration-tests/kotlin-maven-invoker/src/test/java/io/quarkus/kotlin/maven/it/KotlinRemoteDevModeIT.java diff --git a/integration-tests/kotlin/src/test/resources/projects/classic-kotlin/pom.xml b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/pom.xml similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/classic-kotlin/pom.xml rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/pom.xml diff --git a/integration-tests/kotlin/src/test/resources/projects/classic-kotlin/src/main/kotlin/org/acme/GreetingService.kt b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/src/main/kotlin/org/acme/GreetingService.kt similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/classic-kotlin/src/main/kotlin/org/acme/GreetingService.kt rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/src/main/kotlin/org/acme/GreetingService.kt diff --git a/integration-tests/kotlin/src/test/resources/projects/classic-kotlin/src/main/kotlin/org/acme/HelloResource.kt b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/src/main/kotlin/org/acme/HelloResource.kt similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/classic-kotlin/src/main/kotlin/org/acme/HelloResource.kt rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/src/main/kotlin/org/acme/HelloResource.kt diff --git a/integration-tests/kotlin/src/test/resources/projects/classic-kotlin/src/main/kotlin/org/acme/MyApplication.kt b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/src/main/kotlin/org/acme/MyApplication.kt similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/classic-kotlin/src/main/kotlin/org/acme/MyApplication.kt rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/src/main/kotlin/org/acme/MyApplication.kt diff --git a/integration-tests/kotlin/src/test/resources/projects/classic-kotlin/src/main/resources/META-INF/resources/index.html b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/src/main/resources/META-INF/resources/index.html similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/classic-kotlin/src/main/resources/META-INF/resources/index.html rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/src/main/resources/META-INF/resources/index.html diff --git a/integration-tests/kotlin/src/test/resources/projects/classic-kotlin/src/main/resources/application.properties b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/src/main/resources/application.properties similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/classic-kotlin/src/main/resources/application.properties rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/src/main/resources/application.properties diff --git a/integration-tests/kotlin/src/test/resources/projects/classic-kotlin/src/test/kotlin/org/acme/HelloResourceTest.kt b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/src/test/kotlin/org/acme/HelloResourceTest.kt similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/classic-kotlin/src/test/kotlin/org/acme/HelloResourceTest.kt rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/classic-kotlin/src/test/kotlin/org/acme/HelloResourceTest.kt diff --git a/integration-tests/kotlin/src/test/resources/projects/external-reloadable-artifacts/app/pom.xml b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/external-reloadable-artifacts/app/pom.xml similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/external-reloadable-artifacts/app/pom.xml rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/external-reloadable-artifacts/app/pom.xml diff --git a/integration-tests/kotlin/src/test/resources/projects/external-reloadable-artifacts/app/src/main/kotlin/org/acme/GreetingResource.kt b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/external-reloadable-artifacts/app/src/main/kotlin/org/acme/GreetingResource.kt similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/external-reloadable-artifacts/app/src/main/kotlin/org/acme/GreetingResource.kt rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/external-reloadable-artifacts/app/src/main/kotlin/org/acme/GreetingResource.kt diff --git a/integration-tests/kotlin/src/test/resources/projects/external-reloadable-artifacts/app/src/main/resources/application.properties b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/external-reloadable-artifacts/app/src/main/resources/application.properties similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/external-reloadable-artifacts/app/src/main/resources/application.properties rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/external-reloadable-artifacts/app/src/main/resources/application.properties diff --git a/integration-tests/kotlin/src/test/resources/projects/external-reloadable-artifacts/external-lib/pom.xml b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/external-reloadable-artifacts/external-lib/pom.xml similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/external-reloadable-artifacts/external-lib/pom.xml rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/external-reloadable-artifacts/external-lib/pom.xml diff --git a/integration-tests/kotlin/src/test/resources/projects/external-reloadable-artifacts/external-lib/src/main/kotlin/org/acme/lib/Greeting.kt b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/external-reloadable-artifacts/external-lib/src/main/kotlin/org/acme/lib/Greeting.kt similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/external-reloadable-artifacts/external-lib/src/main/kotlin/org/acme/lib/Greeting.kt rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/external-reloadable-artifacts/external-lib/src/main/kotlin/org/acme/lib/Greeting.kt diff --git a/integration-tests/kotlin/src/test/resources/projects/kotlin-compiler-args/pom.xml b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/kotlin-compiler-args/pom.xml similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/kotlin-compiler-args/pom.xml rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/kotlin-compiler-args/pom.xml diff --git a/integration-tests/kotlin/src/test/resources/projects/kotlin-compiler-args/src/main/kotlin/org/acme/GraphQLResource.kt b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/kotlin-compiler-args/src/main/kotlin/org/acme/GraphQLResource.kt similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/kotlin-compiler-args/src/main/kotlin/org/acme/GraphQLResource.kt rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/kotlin-compiler-args/src/main/kotlin/org/acme/GraphQLResource.kt diff --git a/integration-tests/kotlin/src/test/resources/projects/kotlin-compiler-args/src/main/kotlin/org/acme/HelloResource.kt b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/kotlin-compiler-args/src/main/kotlin/org/acme/HelloResource.kt similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/kotlin-compiler-args/src/main/kotlin/org/acme/HelloResource.kt rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/kotlin-compiler-args/src/main/kotlin/org/acme/HelloResource.kt diff --git a/integration-tests/kotlin/src/test/resources/projects/kotlin-compiler-args/src/main/resources/META-INF/resources/index.html b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/kotlin-compiler-args/src/main/resources/META-INF/resources/index.html similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/kotlin-compiler-args/src/main/resources/META-INF/resources/index.html rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/kotlin-compiler-args/src/main/resources/META-INF/resources/index.html diff --git a/integration-tests/kotlin/src/test/resources/projects/kotlin-compiler-args/src/main/resources/application.properties b/integration-tests/kotlin-maven-invoker/src/test/resources/projects/kotlin-compiler-args/src/main/resources/application.properties similarity index 100% rename from integration-tests/kotlin/src/test/resources/projects/kotlin-compiler-args/src/main/resources/application.properties rename to integration-tests/kotlin-maven-invoker/src/test/resources/projects/kotlin-compiler-args/src/main/resources/application.properties diff --git a/integration-tests/kotlin-serialization/pom.xml b/integration-tests/kotlin-serialization/pom.xml deleted file mode 100644 index 99919f36e3dbb..0000000000000 --- a/integration-tests/kotlin-serialization/pom.xml +++ /dev/null @@ -1,136 +0,0 @@ - - - 4.0.0 - - - quarkus-integration-tests-parent - io.quarkus - 999-SNAPSHOT - - - quarkus-integration-test-kotlin-serialization - Quarkus - Integration Tests - Kotlin Serialization - Kotlin Serialization integration tests module - - - 1.3.1 - - - - - io.quarkus - quarkus-rest-kotlin-serialization - 999-SNAPSHOT - - - - io.quarkus - quarkus-arc - - - io.quarkus - quarkus-junit5 - test - - - io.rest-assured - kotlin-extensions - test - - - org.assertj - assertj-core - test - - - - - io.quarkus - quarkus-rest-kotlin-serialization-deployment - ${project.version} - pom - test - - - * - * - - - - - io.quarkus - quarkus-arc-deployment - ${project.version} - pom - test - - - * - * - - - - - - - src/main/kotlin - src/test/kotlin - - - org.jetbrains.kotlin - kotlin-maven-plugin - - - compile - compile - - compile - - - - test-compile - test-compile - - test-compile - - - - - - org.jetbrains.kotlin - kotlin-maven-allopen - ${kotlin.version} - - - org.jetbrains.kotlin - kotlin-maven-serialization - ${kotlin.version} - - - - - all-open - kotlinx-serialization - - - - - - - - io.quarkus - quarkus-maven-plugin - - - - build - - - - - - - - diff --git a/integration-tests/kotlin/pom.xml b/integration-tests/kotlin/pom.xml index c4af49aad8f44..bf5587575cf76 100644 --- a/integration-tests/kotlin/pom.xml +++ b/integration-tests/kotlin/pom.xml @@ -13,69 +13,70 @@ quarkus-integration-test-kotlin Quarkus - Integration Tests - Kotlin + + 1.3.1 + + io.quarkus - quarkus-kotlin-deployment + quarkus-rest-kotlin-serialization + 999-SNAPSHOT + io.quarkus - quarkus-bootstrap-maven-resolver - test + quarkus-arc io.quarkus - quarkus-project-core-extension-codestarts + quarkus-junit5 test - io.quarkus - quarkus-test-maven + io.rest-assured + kotlin-extensions test - org.jetbrains.kotlin - kotlin-test - ${kotlin.version} + org.assertj + assertj-core test + + - org.apache.maven - maven-model + io.quarkus + quarkus-rest-kotlin-serialization-deployment + ${project.version} + pom test + + + * + * + + - org.apache.commons - commons-lang3 + io.quarkus + quarkus-arc-deployment + ${project.version} + pom test + + + * + * + + - - - src/test/resources - true - - + src/main/kotlin + src/test/kotlin - - org.apache.maven.plugins - maven-failsafe-plugin - - - - integration-test - verify - - - - - - ${project.version} - - - org.jetbrains.kotlin kotlin-maven-plugin @@ -95,33 +96,40 @@ + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-maven-serialization + ${kotlin.version} + + + + + all-open + kotlinx-serialization + + + + + - org.apache.maven.plugins - maven-compiler-plugin + io.quarkus + quarkus-maven-plugin - testCompile - test-compile - testCompile + build - - - basic-test-suite - - - basicTests - - - - true - - - + diff --git a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/GreetingApplication.kt b/integration-tests/kotlin/src/main/kotlin/io/quarkus/it/kotser/GreetingApplication.kt similarity index 100% rename from integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/GreetingApplication.kt rename to integration-tests/kotlin/src/main/kotlin/io/quarkus/it/kotser/GreetingApplication.kt diff --git a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/GreetingResource.kt b/integration-tests/kotlin/src/main/kotlin/io/quarkus/it/kotser/GreetingResource.kt similarity index 95% rename from integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/GreetingResource.kt rename to integration-tests/kotlin/src/main/kotlin/io/quarkus/it/kotser/GreetingResource.kt index a1d0804d563c0..bbf30af7b3626 100644 --- a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/GreetingResource.kt +++ b/integration-tests/kotlin/src/main/kotlin/io/quarkus/it/kotser/GreetingResource.kt @@ -85,5 +85,11 @@ class GreetingResource { return emptyMap() } + @GET + @Path("emptySet") + fun emptySet(): Set { + return emptySet() + } + fun reflect() = "hello, world" } diff --git a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/TitleCase.kt b/integration-tests/kotlin/src/main/kotlin/io/quarkus/it/kotser/TitleCase.kt similarity index 100% rename from integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/TitleCase.kt rename to integration-tests/kotlin/src/main/kotlin/io/quarkus/it/kotser/TitleCase.kt diff --git a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/model/Person.kt b/integration-tests/kotlin/src/main/kotlin/io/quarkus/it/kotser/model/Person.kt similarity index 100% rename from integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/model/Person.kt rename to integration-tests/kotlin/src/main/kotlin/io/quarkus/it/kotser/model/Person.kt diff --git a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/model/Person2.kt b/integration-tests/kotlin/src/main/kotlin/io/quarkus/it/kotser/model/Person2.kt similarity index 100% rename from integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/model/Person2.kt rename to integration-tests/kotlin/src/main/kotlin/io/quarkus/it/kotser/model/Person2.kt diff --git a/integration-tests/kotlin-serialization/src/main/resources/application.properties b/integration-tests/kotlin/src/main/resources/application.properties similarity index 100% rename from integration-tests/kotlin-serialization/src/main/resources/application.properties rename to integration-tests/kotlin/src/main/resources/application.properties diff --git a/integration-tests/kotlin-serialization/src/test/kotlin/io/quarkus/it/kotser/ResourceIT.kt b/integration-tests/kotlin/src/test/kotlin/io/quarkus/it/kotser/ResourceIT.kt similarity index 100% rename from integration-tests/kotlin-serialization/src/test/kotlin/io/quarkus/it/kotser/ResourceIT.kt rename to integration-tests/kotlin/src/test/kotlin/io/quarkus/it/kotser/ResourceIT.kt diff --git a/integration-tests/kotlin-serialization/src/test/kotlin/io/quarkus/it/kotser/ResourceTest.kt b/integration-tests/kotlin/src/test/kotlin/io/quarkus/it/kotser/ResourceTest.kt similarity index 97% rename from integration-tests/kotlin-serialization/src/test/kotlin/io/quarkus/it/kotser/ResourceTest.kt rename to integration-tests/kotlin/src/test/kotlin/io/quarkus/it/kotser/ResourceTest.kt index 35d43c86e164f..4f50cae5545b5 100644 --- a/integration-tests/kotlin-serialization/src/test/kotlin/io/quarkus/it/kotser/ResourceTest.kt +++ b/integration-tests/kotlin/src/test/kotlin/io/quarkus/it/kotser/ResourceTest.kt @@ -134,4 +134,9 @@ open class ResourceTest { fun testEmptyMap() { When { get("/emptyList") } Then { statusCode(200) } } + + @Test + fun testEmptySet() { + When { get("/emptySet") } Then { statusCode(200) } + } } diff --git a/integration-tests/kotlin/src/test/kotlin/io/quarkus/it/testing/repro34099/Repro34099Test.kt b/integration-tests/kotlin/src/test/kotlin/io/quarkus/it/testing/repro34099/Repro34099Test.kt new file mode 100644 index 0000000000000..5e63473cbc4b9 --- /dev/null +++ b/integration-tests/kotlin/src/test/kotlin/io/quarkus/it/testing/repro34099/Repro34099Test.kt @@ -0,0 +1,23 @@ +package io.quarkus.it.testing.repro34099 + +import io.quarkus.test.junit.QuarkusTest +import java.time.Duration +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertTimeout + +@QuarkusTest +class Repro34099Test { + @Test + fun javaAssertion() { + Assertions.assertTimeout(Duration.ofSeconds(1)) {} + } + + @Test + @Disabled("https://github.com/quarkusio/quarkus/issues/34099") + // fails with `Linkage loader constraint violation` + fun kotlinAssertion() { + assertTimeout(Duration.ofSeconds(1)) {} + } +} diff --git a/integration-tests/kotlin/src/test/kotlin/io/quarkus/it/testing/repro42000/Repro42000Test.kt b/integration-tests/kotlin/src/test/kotlin/io/quarkus/it/testing/repro42000/Repro42000Test.kt new file mode 100644 index 0000000000000..cbcb4a259fbb1 --- /dev/null +++ b/integration-tests/kotlin/src/test/kotlin/io/quarkus/it/testing/repro42000/Repro42000Test.kt @@ -0,0 +1,42 @@ +package io.quarkus.it.testing.repro42000 + +import io.quarkus.test.junit.QuarkusTest +import java.util.stream.Stream +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +@QuarkusTest +class Repro42000Test { + companion object { + val lambda: (String) -> String = { s -> s } + + fun function(s: String) = s + + @JvmStatic + fun lambdaProvider(): Stream { + return Stream.of(Arguments.of(lambda)) + } + + @JvmStatic + fun functionProvider(): Stream { + return Stream.of(Arguments.of(::function)) + } + } + + @ParameterizedTest + @MethodSource("lambdaProvider") + @Disabled("https://github.com/quarkusio/quarkus/issues/42000") + // fails with `IllegalArgumentException: argument type mismatch` + fun testLambdaProvider(function: (String) -> String) { + assertNotNull(function) + } + + @ParameterizedTest + @MethodSource("functionProvider") + fun testFunctionProvider(function: (String) -> String) { + assertNotNull(function) + } +} diff --git a/integration-tests/kubernetes/maven-invoker-way/src/it/knative-jib-build-and-deploy/pom.xml b/integration-tests/kubernetes/maven-invoker-way/src/it/knative-jib-build-and-deploy/pom.xml index d8e6d3de89042..b071362d8f4c1 100644 --- a/integration-tests/kubernetes/maven-invoker-way/src/it/knative-jib-build-and-deploy/pom.xml +++ b/integration-tests/kubernetes/maven-invoker-way/src/it/knative-jib-build-and-deploy/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-docker-build-and-deploy-deployment/pom.xml b/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-docker-build-and-deploy-deployment/pom.xml index 1b5260d425205..9b4f2b6050145 100644 --- a/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-docker-build-and-deploy-deployment/pom.xml +++ b/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-docker-build-and-deploy-deployment/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-docker-build-and-deploy-statefulset/pom.xml b/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-docker-build-and-deploy-statefulset/pom.xml index edb24abc8d1b8..be073f36896ce 100644 --- a/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-docker-build-and-deploy-statefulset/pom.xml +++ b/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-docker-build-and-deploy-statefulset/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-jib-build-and-deploy/pom.xml b/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-jib-build-and-deploy/pom.xml index 137f8c690949e..9d542d751349e 100644 --- a/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-jib-build-and-deploy/pom.xml +++ b/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-jib-build-and-deploy/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-with-existing-selectorless-manifest/pom.xml b/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-with-existing-selectorless-manifest/pom.xml index 51e88a55f3713..15f7d4cd029eb 100644 --- a/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-with-existing-selectorless-manifest/pom.xml +++ b/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-with-existing-selectorless-manifest/pom.xml @@ -8,7 +8,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-with-grpc-same-server/pom.xml b/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-with-grpc-same-server/pom.xml index 8b381c178dd9d..fa53fb2597e2a 100644 --- a/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-with-grpc-same-server/pom.xml +++ b/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-with-grpc-same-server/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-with-grpc/pom.xml b/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-with-grpc/pom.xml index 849d0b370e237..5238212c27937 100644 --- a/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-with-grpc/pom.xml +++ b/integration-tests/kubernetes/maven-invoker-way/src/it/kubernetes-with-grpc/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/kubernetes/maven-invoker-way/src/it/minikube-with-existing-manifest/pom.xml b/integration-tests/kubernetes/maven-invoker-way/src/it/minikube-with-existing-manifest/pom.xml index ae2bca28a0809..88dd72673ef47 100644 --- a/integration-tests/kubernetes/maven-invoker-way/src/it/minikube-with-existing-manifest/pom.xml +++ b/integration-tests/kubernetes/maven-invoker-way/src/it/minikube-with-existing-manifest/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/kubernetes/maven-invoker-way/src/it/openshift-docker-build-and-deploy/pom.xml b/integration-tests/kubernetes/maven-invoker-way/src/it/openshift-docker-build-and-deploy/pom.xml index 9a1b383999bce..169f0414b97bf 100644 --- a/integration-tests/kubernetes/maven-invoker-way/src/it/openshift-docker-build-and-deploy/pom.xml +++ b/integration-tests/kubernetes/maven-invoker-way/src/it/openshift-docker-build-and-deploy/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/kubernetes/maven-invoker-way/src/it/openshift-s2i-build-and-deploy/pom.xml b/integration-tests/kubernetes/maven-invoker-way/src/it/openshift-s2i-build-and-deploy/pom.xml index 6db87aad3a89a..ea700691d461a 100644 --- a/integration-tests/kubernetes/maven-invoker-way/src/it/openshift-s2i-build-and-deploy/pom.xml +++ b/integration-tests/kubernetes/maven-invoker-way/src/it/openshift-s2i-build-and-deploy/pom.xml @@ -7,7 +7,7 @@ 0.1-SNAPSHOT UTF-8 - 3.5.0 + 3.5.2 17 UTF-8 17 diff --git a/integration-tests/main/src/main/java/io/quarkus/it/testing/repro44320/MyService.java b/integration-tests/main/src/main/java/io/quarkus/it/testing/repro44320/MyService.java new file mode 100644 index 0000000000000..1f2b031c1d5fd --- /dev/null +++ b/integration-tests/main/src/main/java/io/quarkus/it/testing/repro44320/MyService.java @@ -0,0 +1,17 @@ +package io.quarkus.it.testing.repro44320; + +import java.util.Set; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.Unremovable; +import io.quarkus.runtime.Startup; + +@ApplicationScoped +@Startup +@Unremovable +public class MyService { + public Set get() { + return Set.of("a", "b", "c"); + } +} diff --git a/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro13261/Repro13261Test.java b/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro13261/Repro13261Test.java new file mode 100644 index 0000000000000..e6abb234611f7 --- /dev/null +++ b/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro13261/Repro13261Test.java @@ -0,0 +1,24 @@ +package io.quarkus.it.main.testing.repro13261; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import io.quarkus.test.junit.QuarkusTest; + +@Disabled("https://github.com/quarkusio/quarkus/issues/13261") +// fails with `expected: not ` +@QuarkusTest +public class Repro13261Test { + @TempDir + Path tempDir; + + @Test + public void test() { + assertNotNull(tempDir); + } +} diff --git a/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro42006/Repro42006Test.java b/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro42006/Repro42006Test.java new file mode 100644 index 0000000000000..b0929b32df3a6 --- /dev/null +++ b/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro42006/Repro42006Test.java @@ -0,0 +1,43 @@ +package io.quarkus.it.main.testing.repro42006; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.Serializable; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; + +import io.quarkus.test.junit.QuarkusTest; + +@Disabled("https://github.com/quarkusio/quarkus/issues/42006") +// fails with `java.lang.ClassNotFoundException: io.quarkus.it.main.testing.repro42006.Repro42006Test$LambdaProvider$$Lambda$4007/0x000075d5017e8450` +@QuarkusTest +public class Repro42006Test { + @ParameterizedTest + @ArgumentsSource(LambdaProvider.class) + void test(String type, Object lambda) { + assertTrue(lambda.toString().contains("$$Lambda"), "Failed on " + type); + } + + private static class LambdaProvider implements ArgumentsProvider { + @Override + public Stream provideArguments(ExtensionContext context) { + return Stream.of( + Arguments.of("SerializableSupplier", (SerializableSupplier) () -> "foo"), + Arguments.of("SerializableCustom", (SerializableCustom) () -> "bar")); + } + } + + public interface SerializableSupplier extends Supplier, Serializable { + } + + public interface SerializableCustom extends Serializable { + String get(); + } +} diff --git a/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro44320/Repro44320Test.java b/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro44320/Repro44320Test.java new file mode 100644 index 0000000000000..cb4588dbca060 --- /dev/null +++ b/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro44320/Repro44320Test.java @@ -0,0 +1,43 @@ +package io.quarkus.it.main.testing.repro44320; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.HashSet; +import java.util.Set; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import io.quarkus.it.testing.repro44320.MyService; +import io.quarkus.test.junit.QuarkusTest; + +@Disabled("https://github.com/quarkusio/quarkus/issues/44320") +// fails with `You must configure at least one set of arguments for this @ParameterizedTest`, because the `set` is empty +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@QuarkusTest +public class Repro44320Test { + private static Set set = new HashSet<>(); + + @Inject + MyService service; + + @BeforeAll + public void beforeAllTests() { + set = service.get(); + } + + @ParameterizedTest + @MethodSource("getData") + public void test(String key) { + assertNotNull(key); + } + + public Set getData() { + return set; + } +} diff --git a/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro8446/Greeter.java b/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro8446/Greeter.java new file mode 100644 index 0000000000000..8a410d2f0afc8 --- /dev/null +++ b/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro8446/Greeter.java @@ -0,0 +1,5 @@ +package io.quarkus.it.main.testing.repro8446; + +public interface Greeter { + String hello(); +} diff --git a/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro8446/GreeterExtension.java b/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro8446/GreeterExtension.java new file mode 100644 index 0000000000000..05155a0bd61cb --- /dev/null +++ b/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro8446/GreeterExtension.java @@ -0,0 +1,52 @@ +package io.quarkus.it.main.testing.repro8446; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.junit.jupiter.api.extension.TestTemplateInvocationContext; +import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider; + +public class GreeterExtension implements TestTemplateInvocationContextProvider { + @Override + public boolean supportsTestTemplate(ExtensionContext context) { + return context.getTestMethod().map(method -> { + return Arrays.asList(method.getParameterTypes()).contains(Greeter.class); + }).orElse(false); + } + + @Override + public Stream provideTestTemplateInvocationContexts(ExtensionContext context) { + return Stream.of(new HelloTestTemplateInvocationContext(() -> "hello")); + } + + private static class HelloTestTemplateInvocationContext implements TestTemplateInvocationContext, ParameterResolver { + private final Greeter greeter; + + public HelloTestTemplateInvocationContext(Greeter greeter) { + this.greeter = greeter; + } + + @Override + public List getAdditionalExtensions() { + return Collections.singletonList(this); + } + + @Override + public boolean supportsParameter(ParameterContext pc, ExtensionContext extensionContext) + throws ParameterResolutionException { + return pc.getParameter().getType() == Greeter.class; + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + return greeter; + } + } +} diff --git a/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro8446/Repro8446Test.java b/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro8446/Repro8446Test.java new file mode 100644 index 0000000000000..2edab696af117 --- /dev/null +++ b/integration-tests/main/src/test/java/io/quarkus/it/main/testing/repro8446/Repro8446Test.java @@ -0,0 +1,20 @@ +package io.quarkus.it.main.testing.repro8446; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.quarkus.test.junit.QuarkusTest; + +@Disabled("https://github.com/quarkusio/quarkus/issues/8446") +// fails with `IllegalArgumentException: argument type mismatch` +@QuarkusTest +public class Repro8446Test { + @TestTemplate + @ExtendWith(GreeterExtension.class) + public void test(Greeter greeter) { + assertEquals("hello", greeter.hello()); + } +} diff --git a/integration-tests/maven/src/test/resources/__snapshots__/CreateExtensionMojoIT/testCreateQuarkiverseExtension/quarkus-my-quarkiverse-ext_.github_workflows_release-prepare.yml b/integration-tests/maven/src/test/resources/__snapshots__/CreateExtensionMojoIT/testCreateQuarkiverseExtension/quarkus-my-quarkiverse-ext_.github_workflows_release-prepare.yml index aedad9370a2dc..e2da7080b29b7 100644 --- a/integration-tests/maven/src/test/resources/__snapshots__/CreateExtensionMojoIT/testCreateQuarkiverseExtension/quarkus-my-quarkiverse-ext_.github_workflows_release-prepare.yml +++ b/integration-tests/maven/src/test/resources/__snapshots__/CreateExtensionMojoIT/testCreateQuarkiverseExtension/quarkus-my-quarkiverse-ext_.github_workflows_release-prepare.yml @@ -1,6 +1,7 @@ name: Quarkiverse Prepare Release on: + workflow_dispatch: pull_request: types: [ closed ] paths: @@ -13,6 +14,6 @@ concurrency: jobs: prepare-release: name: Prepare Release - if: ${{ github.event.pull_request.merged == true}} + if: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true}} uses: quarkiverse/.github/.github/workflows/prepare-release.yml@main secrets: inherit diff --git a/integration-tests/maven/src/test/resources/__snapshots__/CreateExtensionMojoIT/testCreateStandaloneExtension/my-org-my-own-ext_pom.xml b/integration-tests/maven/src/test/resources/__snapshots__/CreateExtensionMojoIT/testCreateStandaloneExtension/my-org-my-own-ext_pom.xml index 8f3eb160b8699..811917d801feb 100644 --- a/integration-tests/maven/src/test/resources/__snapshots__/CreateExtensionMojoIT/testCreateStandaloneExtension/my-org-my-own-ext_pom.xml +++ b/integration-tests/maven/src/test/resources/__snapshots__/CreateExtensionMojoIT/testCreateStandaloneExtension/my-org-my-own-ext_pom.xml @@ -19,7 +19,7 @@ UTF-8 UTF-8 3.14.0 - 3.5.0 + 3.5.2 diff --git a/integration-tests/mongodb-panache/src/test/java/io/quarkus/it/mongodb/panache/MongodbPanacheResourceTest.java b/integration-tests/mongodb-panache/src/test/java/io/quarkus/it/mongodb/panache/MongodbPanacheResourceTest.java index d07a9733a4744..976dcc1e75243 100644 --- a/integration-tests/mongodb-panache/src/test/java/io/quarkus/it/mongodb/panache/MongodbPanacheResourceTest.java +++ b/integration-tests/mongodb-panache/src/test/java/io/quarkus/it/mongodb/panache/MongodbPanacheResourceTest.java @@ -357,6 +357,7 @@ private void callPersonEndpoint(String endpoint) { .when().get("/q/metrics") .then() .statusCode(200) + .body(CoreMatchers.containsString("mongodb_driver_commands_seconds_max")) .body(CoreMatchers.containsString("mongodb_driver_pool_checkedout")) .body(CoreMatchers.containsString("mongodb_driver_pool_size")) .body(CoreMatchers.containsString("mongodb_driver_pool_waitqueuesize")); diff --git a/integration-tests/oidc-client-registration/src/main/java/io/quarkus/it/keycloak/ClientAuthWithSignedJwtCreator.java b/integration-tests/oidc-client-registration/src/main/java/io/quarkus/it/keycloak/ClientAuthWithSignedJwtCreator.java new file mode 100644 index 0000000000000..a8a710e0ab220 --- /dev/null +++ b/integration-tests/oidc-client-registration/src/main/java/io/quarkus/it/keycloak/ClientAuthWithSignedJwtCreator.java @@ -0,0 +1,143 @@ +package io.quarkus.it.keycloak; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPublicKey; +import java.util.Arrays; + +import jakarta.enterprise.event.Observes; +import jakarta.inject.Singleton; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jose4j.base64url.Base64Url; + +import io.quarkus.logging.Log; +import io.quarkus.oidc.client.registration.ClientMetadata; +import io.quarkus.oidc.client.registration.OidcClientRegistration; +import io.quarkus.runtime.StartupEvent; +import io.smallrye.jwt.build.Jwt; + +@Singleton +public class ClientAuthWithSignedJwtCreator { + + private final KeyPair keyPair; + private final Path signedJwtTokenPath; + private volatile ClientMetadata createdClientMetadata = null; + + ClientAuthWithSignedJwtCreator() { + this.keyPair = generateRsaKeyPair(); + this.signedJwtTokenPath = Path.of("target").resolve("signed-jwt-token"); + } + + /** + * This observer creates client that use authentication with signed JWT, signs a JWT token and stores it as a file. + * This token is valid for 5 minutes and can be used for: https://datatracker.ietf.org/doc/html/rfc7523#section-2.2 + * Quarkus will get the token from the file and use it to get tokens from the token endpoint. + */ + void observe(@Observes StartupEvent event, OidcClientRegistration clientRegistration, + @ConfigProperty(name = "keycloak.url") String keycloakUrl) { + generateRsaKeyPair(); + var requestClientMetadata = new ClientMetadata(createClientMetadataJson(keycloakUrl)); + var registeredClient = clientRegistration.registerClient(requestClientMetadata).await().indefinitely(); + this.createdClientMetadata = registeredClient.metadata(); + var signedJwt = createSignedJwt(keycloakUrl, this.createdClientMetadata.getClientId()); + Log.debugf("Client 'signed-jwt-test' has signed JWT token %s", signedJwt); + storeSignedJwtToken(signedJwt); + } + + ClientMetadata getCreatedClientMetadata() { + return createdClientMetadata; + } + + Path getSignedJwtTokenPath() { + return signedJwtTokenPath; + } + + private void storeSignedJwtToken(String signedJwt) { + try { + Files.writeString(signedJwtTokenPath, signedJwt); + } catch (IOException e) { + throw new RuntimeException("Failed to create signed JWT token", e); + } + } + + private String createSignedJwt(String keycloakUrl, String clientId) { + return Jwt.preferredUserName("alice") + .groups("Contributor") + .issuer(clientId) + .audience(keycloakUrl + "/realms/quarkus/protocol/openid-connect/token") + .subject(clientId) + .sign(keyPair.getPrivate()); + } + + private String createClientMetadataJson(String keycloakUrl) { + RSAPublicKey rsaKey = (RSAPublicKey) keyPair.getPublic(); + String modulus = Base64Url.encode(toIntegerBytes(rsaKey.getModulus())); + String publicExponent = Base64Url.encode(toIntegerBytes(rsaKey.getPublicExponent())); + return """ + { + "redirect_uris" : [ "http://localhost:8081/protected/jwt-bearer-token-file" ], + "token_endpoint_auth_method" : "private_key_jwt", + "grant_types" : [ "client_credentials" ], + "client_name" : "signed-jwt-test", + "client_uri" : "%1$s/auth/realms/quarkus/app", + "jwks" : { + "keys" : [ { + "kid" : "%4$s", + "kty" : "RSA", + "alg" : "RS256", + "use" : "sig", + "e" : "%3$s", + "n" : "%2$s" + } ] + } + } + """ + .formatted(keycloakUrl, modulus, publicExponent, createKeyId()); + } + + private String createKeyId() { + try { + return Base64Url.encode(MessageDigest.getInstance("SHA-256").digest(keyPair.getPrivate().getEncoded())); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed to generate key id", e); + } + } + + private static KeyPair generateRsaKeyPair() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } catch (Exception e) { + throw new RuntimeException("Failed to generate RSA key pair", e); + } + } + + private static byte[] toIntegerBytes(final BigInteger bigInt) { + final int bitlen = bigInt.bitLength(); + // following code comes from the Keycloak project + + final int bytelen = (bitlen + 7) / 8; + final byte[] array = bigInt.toByteArray(); + if (array.length == bytelen) { + // expected number of bytes, return them + return array; + } else if (bytelen < array.length) { + // if array is greater is because the sign bit (it can be only 1 byte more), remove it + return Arrays.copyOfRange(array, array.length - bytelen, array.length); + } else { + // if array is smaller fill it with zeros + final byte[] resizedBytes = new byte[bytelen]; + System.arraycopy(array, 0, resizedBytes, bytelen - array.length, array.length); + return resizedBytes; + } + } + +} diff --git a/integration-tests/oidc-client-registration/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java b/integration-tests/oidc-client-registration/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java index 9d8075d50e34d..1e281f0057658 100644 --- a/integration-tests/oidc-client-registration/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java +++ b/integration-tests/oidc-client-registration/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java @@ -1,5 +1,8 @@ package io.quarkus.it.keycloak; +import static io.quarkus.oidc.common.runtime.config.OidcClientCommonConfig.Credentials.Jwt.Source.BEARER; +import static io.quarkus.oidc.runtime.OidcTenantConfig.ApplicationType.WEB_APP; + import java.net.URI; import java.util.List; import java.util.Map; @@ -34,6 +37,9 @@ public class CustomTenantConfigResolver implements TenantConfigResolver { @Inject OidcClientRegistrations clientRegs; + @Inject + ClientAuthWithSignedJwtCreator clientAuthWithSignedJwtCreator; + @Inject @ConfigProperty(name = "quarkus.oidc.auth-server-url") String authServerUrl; @@ -122,6 +128,23 @@ public Uni resolve(RoutingContext routingContext, } else if (routingContext.request().path().endsWith("/protected/multi2")) { return Uni.createFrom().item(createTenantConfig("registered-client-multi2", regClientsMulti.get("/protected/multi2").metadata())); + } else if (routingContext.normalizedPath().endsWith("/jwt-bearer-token-file")) { + var clientMetadata = clientAuthWithSignedJwtCreator.getCreatedClientMetadata(); + var redirectPath = URI.create(clientMetadata.getRedirectUris().get(0)).getPath(); + var tenantConfig = OidcTenantConfig + .authServerUrl(authServerUrl) + .applicationType(WEB_APP) + .tenantId("registered-client-jwt-bearer-token-file") + .clientName(clientMetadata.getClientName()) + .clientId(clientMetadata.getClientId()) + .authentication().redirectPath(redirectPath).end() + .credentials() + .jwt() + .source(BEARER) + .tokenPath(clientAuthWithSignedJwtCreator.getSignedJwtTokenPath()) + .endCredentials() + .build(); + return Uni.createFrom().item(tenantConfig); } return null; diff --git a/integration-tests/oidc-client-registration/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java b/integration-tests/oidc-client-registration/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java index 81ace6de744cb..63610105b20db 100644 --- a/integration-tests/oidc-client-registration/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java +++ b/integration-tests/oidc-client-registration/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java @@ -61,6 +61,12 @@ public String principalNameMulti2() { return session.getTenantId() + ":" + getClientName() + ":" + principal.getName(); } + @GET + @Path("/jwt-bearer-token-file") + public String jwtBearerTokenFile() { + return session.getTenantId() + ":" + getClientName() + ":" + principal.getName(); + } + private String getClientName() { OidcTenantConfig oidcConfig = tenantConfigBean.getDynamicTenant(session.getTenantId()) .getOidcTenantConfig(); diff --git a/integration-tests/oidc-client-registration/src/test/java/io/quarkus/it/keycloak/OidcClientRegistrationTest.java b/integration-tests/oidc-client-registration/src/test/java/io/quarkus/it/keycloak/OidcClientRegistrationTest.java index 197aace36bd85..c1972cc05cc2a 100644 --- a/integration-tests/oidc-client-registration/src/test/java/io/quarkus/it/keycloak/OidcClientRegistrationTest.java +++ b/integration-tests/oidc-client-registration/src/test/java/io/quarkus/it/keycloak/OidcClientRegistrationTest.java @@ -137,6 +137,24 @@ public void testRegisteredClientMulti2() throws IOException { } } + @Test + public void testRegisteredClientJwtBearerTokenFromFile() throws IOException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/protected/jwt-bearer-token-file"); + + assertEquals("Sign in to quarkus", page.getTitleText()); + + HtmlForm loginForm = page.getForms().get(0); + + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + + TextPage textPage = loginForm.getInputByName("login").click(); + + assertEquals("registered-client-jwt-bearer-token-file:signed-jwt-test:alice", textPage.getContent()); + } + } + private WebClient createWebClient() { WebClient webClient = new WebClient(); webClient.setCssErrorHandler(new SilentCssErrorHandler()); diff --git a/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/FrontendResource.java b/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/FrontendResource.java index 76564a6e68857..04af1e24b3954 100644 --- a/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/FrontendResource.java +++ b/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/FrontendResource.java @@ -33,6 +33,10 @@ public class FrontendResource { @RestClient JwtBearerAuthenticationOidcClient jwtBearerAuthenticationOidcClient; + @Inject + @RestClient + JwtBearerFileAuthenticationOidcClient jwtBearerFileAuthenticationOidcClient; + @Inject @NamedOidcClient("non-standard-response") Tokens tokens; @@ -76,6 +80,12 @@ public String echoTokenJwtBearerAuthentication() { return jwtBearerAuthenticationOidcClient.echoToken(); } + @GET + @Path("echoTokenJwtBearerAuthenticationFromFile") + public String echoTokenJwtBearerAuthenticationFromFile() { + return jwtBearerFileAuthenticationOidcClient.echoToken(); + } + @GET @Path("echoTokenNonStandardResponse") public String echoTokenNonStandardResponse() { diff --git a/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/JwtBearerFileAuthenticationOidcClient.java b/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/JwtBearerFileAuthenticationOidcClient.java new file mode 100644 index 0000000000000..9ee473d775533 --- /dev/null +++ b/integration-tests/oidc-client-wiremock/src/main/java/io/quarkus/it/keycloak/JwtBearerFileAuthenticationOidcClient.java @@ -0,0 +1,17 @@ +package io.quarkus.it.keycloak; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import io.quarkus.oidc.client.filter.OidcClientFilter; + +@RegisterRestClient +@OidcClientFilter("jwtbearer-file") +@Path("/") +public interface JwtBearerFileAuthenticationOidcClient { + + @GET + String echoToken(); +} diff --git a/integration-tests/oidc-client-wiremock/src/main/resources/application.properties b/integration-tests/oidc-client-wiremock/src/main/resources/application.properties index 9285f92bbb684..79316e6568243 100644 --- a/integration-tests/oidc-client-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-client-wiremock/src/main/resources/application.properties @@ -19,6 +19,13 @@ quarkus.oidc-client.jwtbearer.token-path=/tokens-jwtbearer quarkus.oidc-client.jwtbearer.client-id=quarkus-app quarkus.oidc-client.jwtbearer.credentials.jwt.source=bearer +quarkus.oidc-client.jwtbearer-file.auth-server-url=${keycloak.url} +quarkus.oidc-client.jwtbearer-file.discovery-enabled=false +quarkus.oidc-client.jwtbearer-file.token-path=/tokens-jwtbearer-file +quarkus.oidc-client.jwtbearer-file.client-id=quarkus-app +quarkus.oidc-client.jwtbearer-file.credentials.jwt.source=bearer +quarkus.oidc-client.jwtbearer-file.credentials.jwt.token-path=${token-path:} + quarkus.oidc-client.jwtbearer-grant.auth-server-url=${keycloak.url} quarkus.oidc-client.jwtbearer-grant.discovery-enabled=false quarkus.oidc-client.jwtbearer-grant.token-path=/tokens-jwtbearer-grant @@ -88,6 +95,7 @@ quarkus.oidc-client.crash-test.early-tokens-acquisition=false io.quarkus.it.keycloak.ProtectedResourceServiceOidcClient/mp-rest/url=http://localhost:8081/protected io.quarkus.it.keycloak.JwtBearerAuthenticationOidcClient/mp-rest/url=http://localhost:8081/protected +io.quarkus.it.keycloak.JwtBearerFileAuthenticationOidcClient/mp-rest/url=http://localhost:8081/protected io.quarkus.it.keycloak.ProtectedResourceServiceCrashTestClient/mp-rest/url=http://localhost:8081/protected quarkus.log.category."io.quarkus.oidc.client.runtime.OidcClientImpl".min-level=TRACE diff --git a/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java b/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java index ea3ba7842ea0c..f2f7c2cbb50d9 100644 --- a/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java +++ b/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java @@ -4,6 +4,10 @@ import static com.github.tomakehurst.wiremock.client.WireMock.matching; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; import java.util.HashMap; import java.util.Map; @@ -16,6 +20,7 @@ import com.github.tomakehurst.wiremock.core.Options.ChunkedEncodingPolicy; import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import io.smallrye.jwt.build.Jwt; public class KeycloakRealmResourceManager implements QuarkusTestResourceLifecycleManager { @@ -53,6 +58,26 @@ public Map start() { .withHeader("Content-Type", MediaType.APPLICATION_JSON) .withBody( "{\"access_token\":\"access_token_jwt_bearer_grant\", \"expires_in\":4, \"refresh_token\":\"refresh_token_jwt_bearer\"}"))); + String jwtBearerToken = Jwt.preferredUserName("Arnold") + .issuer("https://server.example.com") + .audience("https://service.example.com") + .expiresIn(Duration.ofMinutes(30)) + .signWithSecret("43".repeat(20)); + var jwtBearerTokenPath = Path.of("target").resolve("bearer-token-client-assertion.json"); + try { + Files.writeString(jwtBearerTokenPath, jwtBearerToken); + } catch (IOException e) { + throw new RuntimeException("Failed to prepare file with a client assertion", e); + } + server.stubFor(WireMock.post("/tokens-jwtbearer-file") + .withRequestBody(matching("grant_type=client_credentials&" + + "client_assertion=" + jwtBearerToken + + "&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer")) + .willReturn(WireMock + .aResponse() + .withHeader("Content-Type", MediaType.APPLICATION_JSON) + .withBody( + "{\"access_token\":\"access_token_jwt_bearer\", \"expires_in\":4, \"refresh_token\":\"refresh_token_jwt_bearer\"}"))); server.stubFor(WireMock.post("/tokens_public_client") .withRequestBody(matching("grant_type=password&username=alice&password=alice&client_id=quarkus-app")) .willReturn(WireMock @@ -166,6 +191,7 @@ public Map start() { Map conf = new HashMap<>(); conf.put("keycloak.url", server.baseUrl()); + conf.put("token-path", jwtBearerTokenPath.toString()); return conf; } diff --git a/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java b/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java index f02e864ce4e2b..058836d482e33 100644 --- a/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java +++ b/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java @@ -39,13 +39,21 @@ public class OidcClientTest { WireMockServer server; @Test - public void testEchoTokensJwtBearerAuthentication() { + public void testEchoTokensJwtBearerAuthenticationFromAdditionalAttrs() { RestAssured.when().get("/frontend/echoTokenJwtBearerAuthentication") .then() .statusCode(200) .body(equalTo("access_token_jwt_bearer")); } + @Test + public void testEchoTokensJwtBearerAuthenticationFromFile() { + RestAssured.when().get("/frontend/echoTokenJwtBearerAuthenticationFromFile") + .then() + .statusCode(200) + .body(equalTo("access_token_jwt_bearer")); + } + @Test public void testGetAccessTokenWithConfiguredExpiresIn() { Response r = RestAssured.when().get("/frontend/echoTokenConfiguredExpiresIn"); @@ -58,7 +66,7 @@ public void testGetAccessTokenWithConfiguredExpiresIn() { long expectedExpiresAt = now + 5; long accessTokenExpiresAt = Long.valueOf(data[1]); assertTrue(accessTokenExpiresAt >= expectedExpiresAt - && accessTokenExpiresAt <= expectedExpiresAt + 2); + && accessTokenExpiresAt <= expectedExpiresAt + 4); } @Test diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/SessionExpiredOidcRedirectFilter.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/SessionExpiredOidcRedirectFilter.java index 709b516e758ff..bd26d840475c2 100644 --- a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/SessionExpiredOidcRedirectFilter.java +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/SessionExpiredOidcRedirectFilter.java @@ -10,6 +10,7 @@ import io.quarkus.oidc.Redirect; import io.quarkus.oidc.Redirect.Location; import io.quarkus.oidc.TenantFeature; +import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.runtime.OidcUtils; import io.smallrye.jwt.build.Jwt; @@ -31,7 +32,7 @@ public void filter(OidcRedirectContext context) { } AuthorizationCodeTokens tokens = context.routingContext().get(AuthorizationCodeTokens.class.getName()); - String userName = OidcUtils.decodeJwtContent(tokens.getIdToken()).getString(Claims.preferred_username.name()); + String userName = OidcCommonUtils.decodeJwtContent(tokens.getIdToken()).getString(Claims.preferred_username.name()); String jwe = Jwt.preferredUserName(userName).jwe() .encryptWithSecret(context.oidcTenantConfig().credentials.secret.get()); OidcUtils.createCookie(context.routingContext(), context.oidcTenantConfig(), "session_expired", diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/UnprotectedResource.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/UnprotectedResource.java index 46168c0154bc6..4543696184483 100644 --- a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/UnprotectedResource.java +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/UnprotectedResource.java @@ -21,7 +21,6 @@ import io.quarkus.oidc.client.OidcClient; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; -import io.quarkus.oidc.runtime.OidcUtils; @Path("/public-web-app") public class UnprotectedResource { @@ -54,7 +53,7 @@ public String callback(@QueryParam("code") String code) { grantParams.put(OidcConstants.CODE_FLOW_CODE, code); grantParams.put(OidcConstants.CODE_FLOW_REDIRECT_URI, redirectUriParam); String encodedIdToken = oidcClient.getTokens(grantParams).await().indefinitely().get(OidcConstants.ID_TOKEN_VALUE); - return OidcUtils.decodeJwtContent(encodedIdToken).getString("preferred_username"); + return OidcCommonUtils.decodeJwtContent(encodedIdToken).getString("preferred_username"); } @GET diff --git a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java index 86a2689a2db34..3f2a60b94d61f 100644 --- a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java +++ b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java @@ -34,6 +34,7 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.runtime.OidcUtils; import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; @@ -353,7 +354,7 @@ public void testCodeFlowForceHttpsRedirectUriWithQueryAndPkce() throws Exception String encodedIdToken = decryptedSessionCookieValue.split("\\|")[0]; - JsonObject idToken = OidcUtils.decodeJwtContent(encodedIdToken); + JsonObject idToken = OidcCommonUtils.decodeJwtContent(encodedIdToken); String expiresAt = idToken.getInteger("exp").toString(); page = webClient.getPage(endpointLocationWithoutQueryUri.toURL()); String response = page.getBody().asNormalizedText(); @@ -1211,9 +1212,9 @@ public void testDefaultSessionManagerIdRefreshTokens() throws Exception { String[] parts = sessionCookieValue.split("\\|"); assertEquals(3, parts.length); - assertEquals("ID", OidcUtils.decodeJwtContent(parts[0]).getString("typ")); + assertEquals("ID", OidcCommonUtils.decodeJwtContent(parts[0]).getString("typ")); assertEquals("", parts[1]); - assertEquals("Refresh", OidcUtils.decodeJwtContent(parts[2]).getString("typ")); + assertEquals("Refresh", OidcCommonUtils.decodeJwtContent(parts[2]).getString("typ")); assertNull(getSessionAtCookie(webClient, "tenant-id-refresh-token")); assertNull(getSessionRtCookie(webClient, "tenant-id-refresh-token")); @@ -1354,7 +1355,7 @@ private void checkSingleTokenCookie(Cookie tokenCookie, String type, String decr } } assertEquals(3, tokenParts.length); - JsonObject json = OidcUtils.decodeJwtContent(token); + JsonObject json = OidcCommonUtils.decodeJwtContent(token); assertEquals(type, json.getString("typ")); } @@ -1595,7 +1596,7 @@ private String getStateCookieSavedPath(Cookie stateCookie) { } private String getSavedPathFromJson(String value) { - JsonObject json = new JsonObject(OidcUtils.base64UrlDecode(value)); + JsonObject json = new JsonObject(OidcCommonUtils.base64UrlDecode(value)); return json.getString(OidcUtils.STATE_COOKIE_RESTORE_PATH); } diff --git a/integration-tests/oidc-mtls/pom.xml b/integration-tests/oidc-mtls/pom.xml index 2edad5c91ad20..7b6b331e319be 100644 --- a/integration-tests/oidc-mtls/pom.xml +++ b/integration-tests/oidc-mtls/pom.xml @@ -27,7 +27,6 @@ io.quarkus quarkus-tls-registry - io.quarkus quarkus-junit5 @@ -87,6 +86,33 @@ + + io.smallrye.certs + smallrye-certificate-generator-maven-plugin + + + generate-test-resources + + generate + + + + + + + oidc + + PEM + PKCS12 + + password + backend-service + 2 + true + + + + maven-surefire-plugin diff --git a/integration-tests/oidc-mtls/src/main/resources/application.properties b/integration-tests/oidc-mtls/src/main/resources/application.properties index 69d52fd93aa24..939e259a700ac 100644 --- a/integration-tests/oidc-mtls/src/main/resources/application.properties +++ b/integration-tests/oidc-mtls/src/main/resources/application.properties @@ -1,11 +1,11 @@ quarkus.http.tls-configuration-name=oidc-mtls -quarkus.tls.oidc-mtls.key-store.jks.path=server-keystore.jks -quarkus.tls.oidc-mtls.key-store.jks.password=secret -quarkus.tls.oidc-mtls.trust-store.jks.path=server-truststore.jks -quarkus.tls.oidc-mtls.trust-store.jks.password=password +quarkus.tls.oidc-mtls.key-store.p12.path=target/certificates/oidc-keystore.p12 +quarkus.tls.oidc-mtls.key-store.p12.password=password +quarkus.tls.oidc-mtls.trust-store.p12.path=target/certificates/oidc-server-truststore.p12 +quarkus.tls.oidc-mtls.trust-store.p12.password=password quarkus.http.auth.inclusive=true quarkus.http.ssl.client-auth=REQUIRED quarkus.http.insecure-requests=DISABLED -quarkus.native.additional-build-args=-H:IncludeResources=.*\\.jks +quarkus.native.additional-build-args=-H:IncludeResources=target/certificates/.*\\.p12 diff --git a/integration-tests/oidc-mtls/src/main/resources/server-keystore.jks b/integration-tests/oidc-mtls/src/main/resources/server-keystore.jks deleted file mode 100644 index da33e8e7a1668..0000000000000 Binary files a/integration-tests/oidc-mtls/src/main/resources/server-keystore.jks and /dev/null differ diff --git a/integration-tests/oidc-mtls/src/main/resources/server-truststore.jks b/integration-tests/oidc-mtls/src/main/resources/server-truststore.jks deleted file mode 100644 index 8ec8e126507b6..0000000000000 Binary files a/integration-tests/oidc-mtls/src/main/resources/server-truststore.jks and /dev/null differ diff --git a/integration-tests/oidc-mtls/src/test/java/io/quarkus/it/oidc/OidcMtlsTest.java b/integration-tests/oidc-mtls/src/test/java/io/quarkus/it/oidc/OidcMtlsTest.java index ce4b2cd482cad..458c37b26b1ea 100644 --- a/integration-tests/oidc-mtls/src/test/java/io/quarkus/it/oidc/OidcMtlsTest.java +++ b/integration-tests/oidc-mtls/src/test/java/io/quarkus/it/oidc/OidcMtlsTest.java @@ -27,7 +27,7 @@ @QuarkusTest public class OidcMtlsTest { - @TestHTTPResource(ssl = true) + @TestHTTPResource(tls = true) URL url; KeycloakTestClient keycloakClient = new KeycloakTestClient(); @@ -46,7 +46,7 @@ public void testGetIdentityNames() throws Exception { .indefinitely(); assertEquals(200, resp.statusCode()); String name = resp.bodyAsString(); - assertEquals("Identities: CN=client, alice", name); + assertEquals("Identities: CN=backend-service, alice", name); // HTTP 401, invalid token resp = webClient.get("/service/name") @@ -63,18 +63,18 @@ private WebClientOptions createWebClientOptions() throws Exception { WebClientOptions webClientOptions = new WebClientOptions().setDefaultHost(url.getHost()) .setDefaultPort(url.getPort()).setSsl(true).setVerifyHost(false); - byte[] keyStoreData = getFileContent(Paths.get("client-keystore.jks")); + byte[] keyStoreData = getFileContent(Paths.get("target/certificates/oidc-client-keystore.p12")); KeyStoreOptions keyStoreOptions = new KeyStoreOptions() .setPassword("password") .setValue(Buffer.buffer(keyStoreData)) - .setType("JKS"); + .setType("PKCS12"); webClientOptions.setKeyCertOptions(keyStoreOptions); - byte[] trustStoreData = getFileContent(Paths.get("client-truststore.jks")); + byte[] trustStoreData = getFileContent(Paths.get("target/certificates/oidc-client-truststore.p12")); KeyStoreOptions trustStoreOptions = new KeyStoreOptions() - .setPassword("secret") + .setPassword("password") .setValue(Buffer.buffer(trustStoreData)) - .setType("JKS"); + .setType("PKCS12"); webClientOptions.setTrustOptions(trustStoreOptions); return webClientOptions; diff --git a/integration-tests/oidc-mtls/src/test/resources/client-keystore.jks b/integration-tests/oidc-mtls/src/test/resources/client-keystore.jks deleted file mode 100644 index cf6d6ba454864..0000000000000 Binary files a/integration-tests/oidc-mtls/src/test/resources/client-keystore.jks and /dev/null differ diff --git a/integration-tests/oidc-mtls/src/test/resources/client-truststore.jks b/integration-tests/oidc-mtls/src/test/resources/client-truststore.jks deleted file mode 100644 index da33e8e7a1668..0000000000000 Binary files a/integration-tests/oidc-mtls/src/test/resources/client-truststore.jks and /dev/null differ diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java index 3feda8cc8e90f..ed6fce21ac10a 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java @@ -406,7 +406,7 @@ public void testCodeFlowUserInfoCachedInIdToken() throws Exception { Thread.sleep(3000); // Refresh token is available but it is expired, so no token endpoint call is expected - assertTrue((System.currentTimeMillis() / 1000) > OidcUtils.decodeJwtContent(refreshJwtToken) + assertTrue((System.currentTimeMillis() / 1000) > OidcCommonUtils.decodeJwtContent(refreshJwtToken) .getLong(Claims.exp.name())); webClient.getOptions().setRedirectEnabled(false); @@ -595,7 +595,7 @@ private JsonObject decryptIdToken(WebClient webClient, String tenantId) throws E String encodedIdToken = decryptedSessionCookie.split("\\|")[0]; - return OidcUtils.decodeJwtContent(encodedIdToken); + return OidcCommonUtils.decodeJwtContent(encodedIdToken); } private WebClient createWebClient() { diff --git a/integration-tests/oidc/pom.xml b/integration-tests/oidc/pom.xml index ff0b6fdd56069..e63f4b8707ff2 100644 --- a/integration-tests/oidc/pom.xml +++ b/integration-tests/oidc/pom.xml @@ -132,6 +132,33 @@ + + io.smallrye.certs + smallrye-certificate-generator-maven-plugin + + + generate-test-resources + + generate + + + + + + + oidc + + PEM + PKCS12 + + password + backend-service + 2 + true + + + + diff --git a/integration-tests/oidc/src/main/resources/application.properties b/integration-tests/oidc/src/main/resources/application.properties index d8b1ec529ad7c..e4552113a34b4 100644 --- a/integration-tests/oidc/src/main/resources/application.properties +++ b/integration-tests/oidc/src/main/resources/application.properties @@ -1,23 +1,24 @@ quarkus.keycloak.devservices.create-realm=false quarkus.keycloak.devservices.start-command=start --https-client-auth=required --hostname-strict=false --https-key-store-file=/etc/server-keystore.p12 --https-trust-store-file=/etc/server-truststore.p12 --https-trust-store-password=password --spi-user-profile-declarative-user-profile-config-file=/opt/keycloak/upconfig.json -quarkus.keycloak.devservices.resource-aliases.keystore=server-keystore.p12 -quarkus.keycloak.devservices.resource-aliases.truststore=server-truststore.p12 +quarkus.keycloak.devservices.resource-aliases.keystore=target/certificates/oidc-keystore.p12 +quarkus.keycloak.devservices.resource-aliases.truststore=target/certificates/oidc-server-truststore.p12 quarkus.keycloak.devservices.resource-mappings.keystore=/etc/server-keystore.p12 quarkus.keycloak.devservices.resource-mappings.truststore=/etc/server-truststore.p12 quarkus.oidc.token.principal-claim=email -quarkus.oidc.tls.verification=required -quarkus.oidc.tls.trust-store-file=client-truststore.p12 +quarkus.oidc.tls.verification=certificate-validation +quarkus.oidc.tls.trust-store-file=target/certificates/oidc-client-truststore.p12 quarkus.oidc.tls.trust-store-password=password -quarkus.oidc.tls.key-store-file=client-keystore.p12 +quarkus.oidc.tls.key-store-file=target/certificates/oidc-client-keystore.p12 quarkus.oidc.tls.key-store-password=password %tls-registry.quarkus.oidc.tls.tls-configuration-name=oidc-tls -%tls-registry.quarkus.tls.oidc-tls.key-store.jks.path=client-keystore.p12 +%tls-registry.quarkus.tls.oidc-tls.key-store.jks.path=target/certificates/oidc-client-keystore.p12 %tls-registry.quarkus.tls.oidc-tls.key-store.jks.password=password -%tls-registry.quarkus.tls.oidc-tls.trust-store.jks.path=client-truststore.p12 +%tls-registry.quarkus.tls.oidc-tls.trust-store.jks.path=target/certificates/oidc-client-truststore.p12 %tls-registry.quarkus.tls.oidc-tls.trust-store.jks.password=password +%tls-registry.quarkus.tls.oidc-tls.hostname-verification-algorithm=NONE %tls-registry.quarkus.oidc.tls.verification= %tls-registry.quarkus.oidc.tls.trust-store-file= %tls-registry.quarkus.oidc.tls.trust-store-password= diff --git a/integration-tests/oidc/src/main/resources/client-keystore.p12 b/integration-tests/oidc/src/main/resources/client-keystore.p12 deleted file mode 100644 index 11df9af88cd73..0000000000000 Binary files a/integration-tests/oidc/src/main/resources/client-keystore.p12 and /dev/null differ diff --git a/integration-tests/oidc/src/main/resources/client-truststore.p12 b/integration-tests/oidc/src/main/resources/client-truststore.p12 deleted file mode 100644 index 8a9cefe2f5506..0000000000000 Binary files a/integration-tests/oidc/src/main/resources/client-truststore.p12 and /dev/null differ diff --git a/integration-tests/oidc/src/main/resources/server-keystore.p12 b/integration-tests/oidc/src/main/resources/server-keystore.p12 deleted file mode 100644 index 6e476f513ef30..0000000000000 Binary files a/integration-tests/oidc/src/main/resources/server-keystore.p12 and /dev/null differ diff --git a/integration-tests/oidc/src/main/resources/server-truststore.p12 b/integration-tests/oidc/src/main/resources/server-truststore.p12 deleted file mode 100644 index d006d5d2dd43e..0000000000000 Binary files a/integration-tests/oidc/src/main/resources/server-truststore.p12 and /dev/null differ diff --git a/integration-tests/oidc/src/main/resources/upconfig.json b/integration-tests/oidc/src/main/resources/upconfig.json deleted file mode 100644 index 8487089bc90fd..0000000000000 --- a/integration-tests/oidc/src/main/resources/upconfig.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "attributes": [ - { - "name": "username", - "displayName": "${username}", - "permissions": { - "view": ["admin", "user"], - "edit": ["admin", "user"] - }, - "validations": { - "length": { "min": 3, "max": 255 }, - "username-prohibited-characters": {}, - "up-username-not-idn-homograph": {} - } - }, - { - "name": "email", - "displayName": "${email}", - "permissions": { - "view": ["admin", "user"], - "edit": ["admin", "user"] - }, - "validations": { - "email" : {}, - "length": { "max": 255 } - } - }, - { - "name": "firstName", - "displayName": "${firstName}", - "permissions": { - "view": ["admin", "user"], - "edit": ["admin", "user"] - }, - "validations": { - "length": { "max": 255 }, - "person-name-prohibited-characters": {} - } - }, - { - "name": "lastName", - "displayName": "${lastName}", - "permissions": { - "view": ["admin", "user"], - "edit": ["admin", "user"] - }, - "validations": { - "length": { "max": 255 }, - "person-name-prohibited-characters": {} - } - } - ], - "groups": [ - { - "name": "user-metadata", - "displayHeader": "User metadata", - "displayDescription": "Attributes, which refer to user metadata" - } - ] -} \ No newline at end of file diff --git a/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/AbstractBearerTokenAuthorizationTest.java b/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/AbstractBearerTokenAuthorizationTest.java index a1ec95be5d378..608e8af04fceb 100644 --- a/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/AbstractBearerTokenAuthorizationTest.java +++ b/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/AbstractBearerTokenAuthorizationTest.java @@ -16,7 +16,9 @@ public abstract class AbstractBearerTokenAuthorizationTest { - KeycloakTestClient client = new KeycloakTestClient(new Tls()); + KeycloakTestClient client = new KeycloakTestClient( + new Tls("target/certificates/oidc-client-keystore.p12", + "target/certificates/oidc-client-truststore.p12")); @Test public void testSecureAccessSuccessWithCors() { diff --git a/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/KeycloakXTestResourceLifecycleManager.java b/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/KeycloakXTestResourceLifecycleManager.java index 57e855d482d07..dc9863d595683 100644 --- a/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/KeycloakXTestResourceLifecycleManager.java +++ b/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/KeycloakXTestResourceLifecycleManager.java @@ -22,7 +22,9 @@ public class KeycloakXTestResourceLifecycleManager private static final String KEYCLOAK_REALM = "quarkus"; private static final String KEYCLOAK_SERVICE_CLIENT = "quarkus-app"; - final KeycloakTestClient client = new KeycloakTestClient(new Tls()); + final KeycloakTestClient client = new KeycloakTestClient( + new Tls("target/certificates/oidc-client-keystore.p12", + "target/certificates/oidc-client-truststore.p12")); @Override public Map start() { diff --git a/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/WebsocketOidcTestCase.java b/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/WebsocketOidcTestCase.java index 3a75d88294dc4..3c3323e40562d 100644 --- a/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/WebsocketOidcTestCase.java +++ b/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/WebsocketOidcTestCase.java @@ -27,7 +27,9 @@ public class WebsocketOidcTestCase { @TestHTTPResource("secured-hello") URI wsUri; - KeycloakTestClient client = new KeycloakTestClient(new Tls()); + KeycloakTestClient client = new KeycloakTestClient( + new Tls("target/certificates/oidc-client-keystore.p12", + "target/certificates/oidc-client-truststore.p12")); @Test public void websocketTest() throws Exception { diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 356cb2336c5c3..6376d450f75fa 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -304,7 +304,7 @@ maven scala kotlin - kotlin-serialization + kotlin-maven-invoker mongodb-panache mongodb-panache-kotlin mongodb-rest-data-panache diff --git a/integration-tests/qute/src/main/java/io/quarkus/it/qute/JsonResource.java b/integration-tests/qute/src/main/java/io/quarkus/it/qute/JsonResource.java new file mode 100644 index 0000000000000..9699c5a7d1fc9 --- /dev/null +++ b/integration-tests/qute/src/main/java/io/quarkus/it/qute/JsonResource.java @@ -0,0 +1,24 @@ +package io.quarkus.it.qute; + +import jakarta.inject.Inject; +import jakarta.json.JsonObject; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; + +@Path("json") +public class JsonResource { + + @Inject + Template hello; + + @POST + @Produces(MediaType.TEXT_HTML) + public TemplateInstance get(JsonObject request) { + return hello.data("name", request.get("name")); + } +} diff --git a/integration-tests/qute/src/test/java/io/quarkus/it/qute/QuteTestCase.java b/integration-tests/qute/src/test/java/io/quarkus/it/qute/QuteTestCase.java index 81c791ea56e18..5124c67bd95b7 100644 --- a/integration-tests/qute/src/test/java/io/quarkus/it/qute/QuteTestCase.java +++ b/integration-tests/qute/src/test/java/io/quarkus/it/qute/QuteTestCase.java @@ -28,6 +28,18 @@ public void testTemplates() throws InterruptedException { .body(containsString("Hello Ciri!")); RestAssured.when().get("/beer").then().body(containsString("Beer Pilsner, completed: true, done: true")); RestAssured.when().get("/defaultmethod").then().body(containsString("Hello MK")); + RestAssured + .given() + .contentType("application/json") + .body(""" + { + "name": "foo" + } + """) + .when().post("/json") + .then() + .statusCode(200) + .body(containsString("foo")); } @Test diff --git a/integration-tests/reactive-messaging-rabbitmq-devservices/src/test/java/io/quarkus/it/rabbitmq/RabbitMQDevServiceTopologyTest.java b/integration-tests/reactive-messaging-rabbitmq-devservices/src/test/java/io/quarkus/it/rabbitmq/RabbitMQDevServiceTopologyTest.java index 64d0f0a4d1c9a..b845891c91efb 100644 --- a/integration-tests/reactive-messaging-rabbitmq-devservices/src/test/java/io/quarkus/it/rabbitmq/RabbitMQDevServiceTopologyTest.java +++ b/integration-tests/reactive-messaging-rabbitmq-devservices/src/test/java/io/quarkus/it/rabbitmq/RabbitMQDevServiceTopologyTest.java @@ -1,12 +1,11 @@ package io.quarkus.it.rabbitmq; -import static io.restassured.RestAssured.get; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.equalTo; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.junit.jupiter.api.Test; +import io.quarkus.test.common.DevServicesContext; import io.quarkus.test.junit.QuarkusTest; import io.restassured.RestAssured; import io.restassured.http.ContentType; @@ -14,20 +13,25 @@ @QuarkusTest public class RabbitMQDevServiceTopologyTest { - @ConfigProperty(name = "rabbitmq-username") - String username; + DevServicesContext context; - @ConfigProperty(name = "rabbitmq-password") - String password; + public String getUsername() { + return context.devServicesProperties().get("rabbitmq-username"); + } + + public String getPassword() { + return context.devServicesProperties().get("rabbitmq-password"); + } - @ConfigProperty(name = "rabbitmq-http-port") - int rabbitMqHttpPort; + public int getRabbitMqHttpPort() { + return Integer.parseInt(context.devServicesProperties().get("rabbitmq-http-port")); + } @Test public void testVhosts() { RestAssured.given() - .port(rabbitMqHttpPort) - .auth().preemptive().basic(username, password) + .port(getRabbitMqHttpPort()) + .auth().preemptive().basic(getUsername(), getPassword()) .when().get("/api/vhosts") .then() .statusCode(200) @@ -38,8 +42,8 @@ public void testVhosts() { @Test public void testExchanges() { RestAssured.given() - .port(rabbitMqHttpPort) - .auth().preemptive().basic(username, password) + .port(getRabbitMqHttpPort()) + .auth().preemptive().basic(getUsername(), getPassword()) .when().get("/api/exchanges/my-vhost-1/my-exchange-1") .then() .statusCode(200) @@ -50,8 +54,8 @@ public void testExchanges() { @Test public void testQueues() { RestAssured.given() - .port(rabbitMqHttpPort) - .auth().preemptive().basic(username, password) + .port(getRabbitMqHttpPort()) + .auth().preemptive().basic(getUsername(), getPassword()) .when().get("/api/queues/my-vhost-1/my-queue-1") .then() .statusCode(200) @@ -62,8 +66,8 @@ public void testQueues() { @Test public void testBindings() { RestAssured.given() - .port(rabbitMqHttpPort) - .auth().preemptive().basic(username, password) + .port(getRabbitMqHttpPort()) + .auth().preemptive().basic(getUsername(), getPassword()) .when().get("/api/bindings/my-vhost-1/e/my-exchange-1/q/my-queue-1") .then() .statusCode(200) diff --git a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/LoginResource.java b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/LoginResource.java index e14bbfbc04f73..57314c65f97cc 100644 --- a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/LoginResource.java +++ b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/LoginResource.java @@ -9,12 +9,12 @@ import org.jboss.resteasy.reactive.RestForm; -import io.quarkus.hibernate.reactive.panache.common.runtime.ReactiveTransactional; +import io.quarkus.hibernate.reactive.panache.common.WithTransaction; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnLoginResponse; import io.quarkus.security.webauthn.WebAuthnRegisterResponse; import io.quarkus.security.webauthn.WebAuthnSecurity; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; import io.vertx.ext.web.RoutingContext; @Path("") @@ -25,35 +25,36 @@ public class LoginResource { @Path("/login") @POST - @ReactiveTransactional - public Uni login(@RestForm String userName, + @WithTransaction + public Uni login(@RestForm String username, @BeanParam WebAuthnLoginResponse webAuthnResponse, RoutingContext ctx) { // Input validation - if (userName == null || userName.isEmpty() + if (username == null || username.isEmpty() || !webAuthnResponse.isSet() || !webAuthnResponse.isValid()) { return Uni.createFrom().item(Response.status(Status.BAD_REQUEST).build()); } - Uni userUni = User.findByUserName(userName); + Uni userUni = User.findByUsername(username); return userUni.flatMap(user -> { if (user == null) { // Invalid user return Uni.createFrom().item(Response.status(Status.BAD_REQUEST).build()); } - Uni authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx); + Uni authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx); return authenticator // bump the auth counter .invoke(auth -> user.webAuthnCredential.counter = auth.getCounter()) .map(auth -> { // make a login cookie - this.webAuthnSecurity.rememberUser(auth.getUserName(), ctx); + this.webAuthnSecurity.rememberUser(auth.getUsername(), ctx); return Response.ok().build(); }) // handle login failure .onFailure().recoverWithItem(x -> { + x.printStackTrace(); // make a proper error response return Response.status(Status.BAD_REQUEST).build(); }); @@ -63,30 +64,30 @@ public Uni login(@RestForm String userName, @Path("/register") @POST - @ReactiveTransactional - public Uni register(@RestForm String userName, + @WithTransaction + public Uni register(@RestForm String username, @BeanParam WebAuthnRegisterResponse webAuthnResponse, RoutingContext ctx) { // Input validation - if (userName == null || userName.isEmpty() + if (username == null || username.isEmpty() || !webAuthnResponse.isSet() || !webAuthnResponse.isValid()) { return Uni.createFrom().item(Response.status(Status.BAD_REQUEST).build()); } - Uni userUni = User.findByUserName(userName); + Uni userUni = User.findByUsername(username); return userUni.flatMap(user -> { if (user != null) { // Duplicate user return Uni.createFrom().item(Response.status(Status.BAD_REQUEST).build()); } - Uni authenticator = this.webAuthnSecurity.register(webAuthnResponse, ctx); + Uni authenticator = this.webAuthnSecurity.register(username, webAuthnResponse, ctx); return authenticator // store the user .flatMap(auth -> { User newUser = new User(); - newUser.userName = auth.getUserName(); + newUser.username = auth.getUsername(); WebAuthnCredential credential = new WebAuthnCredential(auth, newUser); return credential.persist() .flatMap(c -> newUser. persist()); @@ -94,12 +95,13 @@ public Uni register(@RestForm String userName, }) .map(newUser -> { // make a login cookie - this.webAuthnSecurity.rememberUser(newUser.userName, ctx); + this.webAuthnSecurity.rememberUser(newUser.username, ctx); return Response.ok().build(); }) // handle login failure .onFailure().recoverWithItem(x -> { // make a proper error response + x.printStackTrace(); return Response.status(Status.BAD_REQUEST).build(); }); diff --git a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/MyWebAuthnSetup.java b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/MyWebAuthnSetup.java index 26a5891c715d5..a3e52a38e0a6d 100644 --- a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/MyWebAuthnSetup.java +++ b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/MyWebAuthnSetup.java @@ -1,6 +1,5 @@ package io.quarkus.it.security.webauthn; -import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -8,90 +7,60 @@ import jakarta.enterprise.context.ApplicationScoped; -import io.quarkus.hibernate.reactive.panache.common.runtime.ReactiveTransactional; +import io.quarkus.hibernate.reactive.panache.common.WithTransaction; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnUserProvider; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.AttestationCertificates; -import io.vertx.ext.auth.webauthn.Authenticator; @ApplicationScoped public class MyWebAuthnSetup implements WebAuthnUserProvider { - @ReactiveTransactional + @WithTransaction @Override - public Uni> findWebAuthnCredentialsByUserName(String userName) { - return WebAuthnCredential.findByUserName(userName) - .flatMap(MyWebAuthnSetup::toAuthenticators); + public Uni> findByUsername(String username) { + return WebAuthnCredential.findByUsername(username) + .map(list -> list.stream().map(WebAuthnCredential::toWebAuthnCredentialRecord).toList()); } - @ReactiveTransactional + @WithTransaction @Override - public Uni> findWebAuthnCredentialsByCredID(String credID) { - return WebAuthnCredential.findByCredID(credID) - .flatMap(MyWebAuthnSetup::toAuthenticators); + public Uni findByCredentialId(String credentialId) { + return WebAuthnCredential.findByCredentialId(credentialId) + .onItem().ifNull().failWith(() -> new RuntimeException("No such credentials")) + .map(WebAuthnCredential::toWebAuthnCredentialRecord); } - @ReactiveTransactional + @WithTransaction @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { - // leave the scooby user to the manual endpoint, because if we do it here it will be - // created/updated twice - if (authenticator.getUserName().equals("scooby")) - return Uni.createFrom().nullItem(); - return User.findByUserName(authenticator.getUserName()) - .flatMap(user -> { - // new user - if (user == null) { - User newUser = new User(); - newUser.userName = authenticator.getUserName(); - WebAuthnCredential credential = new WebAuthnCredential(authenticator, newUser); - return credential.persist() - .flatMap(c -> newUser.persist()) - .onItem().ignore().andContinueWithNull(); - } else { - // existing user - user.webAuthnCredential.counter = authenticator.getCounter(); - return Uni.createFrom().nullItem(); - } - }); - } - - private static Uni> toAuthenticators(List dbs) { - // can't call combine/uni on empty list - if (dbs.isEmpty()) - return Uni.createFrom().item(Collections.emptyList()); - List> ret = new ArrayList<>(dbs.size()); - for (WebAuthnCredential db : dbs) { - ret.add(toAuthenticator(db)); + public Uni store(WebAuthnCredentialRecord credentialRecord) { + // this user is handled in the LoginResource endpoint manually + if (credentialRecord.getUsername().equals("scooby")) { + return Uni.createFrom().voidItem(); } - return Uni.combine().all().unis(ret).combinedWith(f -> (List) f); + User newUser = new User(); + newUser.username = credentialRecord.getUsername(); + WebAuthnCredential credential = new WebAuthnCredential(credentialRecord, newUser); + return credential.persist() + .flatMap(c -> newUser.persist()) + .onItem().ignore().andContinueWithNull(); } - private static Uni toAuthenticator(WebAuthnCredential credential) { - return credential.fetch(credential.x5c) - .map(x5c -> { - Authenticator ret = new Authenticator(); - ret.setAaguid(credential.aaguid); - AttestationCertificates attestationCertificates = new AttestationCertificates(); - attestationCertificates.setAlg(credential.alg); - List x5cs = new ArrayList<>(x5c.size()); - for (WebAuthnCertificate webAuthnCertificate : x5c) { - x5cs.add(webAuthnCertificate.x5c); + @WithTransaction + @Override + public Uni update(String credentialId, long counter) { + return WebAuthnCredential.findByCredentialId(credentialId) + .invoke(credential -> { + // this user is handled in the LoginResource endpoint manually + if (!credential.user.username.equals("scooby")) { + credential.counter = counter; } - ret.setAttestationCertificates(attestationCertificates); - ret.setCounter(credential.counter); - ret.setCredID(credential.credID); - ret.setFmt(credential.fmt); - ret.setPublicKey(credential.publicKey); - ret.setType(credential.type); - ret.setUserName(credential.userName); - return ret; - }); + }) + .onItem().ignore().andContinueWithNull(); } @Override - public Set getRoles(String userId) { - if (userId.equals("admin")) { + public Set getRoles(String username) { + if (username.equals("admin")) { Set ret = new HashSet<>(); ret.add("user"); ret.add("admin"); diff --git a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/User.java b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/User.java index dc4861726bca8..411027ea31820 100644 --- a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/User.java +++ b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/User.java @@ -13,13 +13,13 @@ public class User extends PanacheEntity { @Column(unique = true) - public String userName; + public String username; // non-owning side, so we can add more credentials later @OneToOne(mappedBy = "user") public WebAuthnCredential webAuthnCredential; - public static Uni findByUserName(String userName) { - return find("userName", userName).firstResult(); + public static Uni findByUsername(String username) { + return find("username", username).firstResult(); } } diff --git a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/WebAuthnCredential.java b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/WebAuthnCredential.java index b6d6fd0f9396b..6350f735ea63e 100644 --- a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/WebAuthnCredential.java +++ b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/WebAuthnCredential.java @@ -1,76 +1,39 @@ package io.quarkus.it.security.webauthn; -import java.util.ArrayList; import java.util.List; +import java.util.UUID; import jakarta.persistence.Entity; -import jakarta.persistence.OneToMany; +import jakarta.persistence.Id; import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import io.quarkus.hibernate.reactive.panache.PanacheEntity; +import io.quarkus.hibernate.reactive.panache.PanacheEntityBase; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord.RequiredPersistedData; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; -import io.vertx.ext.auth.webauthn.PublicKeyCredential; -@Table(uniqueConstraints = @UniqueConstraint(columnNames = { "userName", "credID" })) @Entity -public class WebAuthnCredential extends PanacheEntity { - - /** - * The username linked to this authenticator - */ - public String userName; - - /** - * The type of key (must be "public-key") - */ - public String type = "public-key"; +public class WebAuthnCredential extends PanacheEntityBase { /** * The non user identifiable id for the authenticator */ + @Id public String credID; /** * The public key associated with this authenticator */ - public String publicKey; + public byte[] publicKey; + + public long publicKeyAlgorithm; /** * The signature counter of the authenticator to prevent replay attacks */ public long counter; - public String aaguid; - - /** - * The Authenticator attestation certificates object, a JSON like: - * - *

    -     * {@code
    -     *   {
    -     *     "alg": "string",
    -     *     "x5c": [
    -     *       "base64"
    -     *     ]
    -     *   }
    -     * }
    -     * 
    - */ - /** - * The algorithm used for the public credential - */ - public PublicKeyCredential alg; - - /** - * The list of X509 certificates encoded as base64url. - */ - @OneToMany(mappedBy = "webAuthnCredential") - public List x5c = new ArrayList<>(); - - public String fmt; + public UUID aaguid; // owning side @OneToOne @@ -79,35 +42,29 @@ public class WebAuthnCredential extends PanacheEntity { public WebAuthnCredential() { } - public WebAuthnCredential(Authenticator authenticator, User user) { - aaguid = authenticator.getAaguid(); - if (authenticator.getAttestationCertificates() != null) - alg = authenticator.getAttestationCertificates().getAlg(); - counter = authenticator.getCounter(); - credID = authenticator.getCredID(); - fmt = authenticator.getFmt(); - publicKey = authenticator.getPublicKey(); - type = authenticator.getType(); - userName = authenticator.getUserName(); - if (authenticator.getAttestationCertificates() != null - && authenticator.getAttestationCertificates().getX5c() != null) { - for (String x5c : authenticator.getAttestationCertificates().getX5c()) { - WebAuthnCertificate cert = new WebAuthnCertificate(); - cert.x5c = x5c; - cert.webAuthnCredential = this; - this.x5c.add(cert); - } - } + public WebAuthnCredential(WebAuthnCredentialRecord credentialRecord, User user) { + RequiredPersistedData requiredPersistedData = credentialRecord.getRequiredPersistedData(); + aaguid = requiredPersistedData.aaguid(); + counter = requiredPersistedData.counter(); + credID = requiredPersistedData.credentialId(); + publicKey = requiredPersistedData.publicKey(); + publicKeyAlgorithm = requiredPersistedData.publicKeyAlgorithm(); this.user = user; user.webAuthnCredential = this; } - public static Uni> findByUserName(String userName) { - return list("userName", userName); + public WebAuthnCredentialRecord toWebAuthnCredentialRecord() { + return WebAuthnCredentialRecord + .fromRequiredPersistedData( + new RequiredPersistedData(user.username, credID, aaguid, publicKey, publicKeyAlgorithm, counter)); + } + + public static Uni> findByUsername(String username) { + return list("user.username", username); } - public static Uni> findByCredID(String credID) { - return list("credID", credID); + public static Uni findByCredentialId(String credID) { + return findById(credID); } public Uni fetch(T association) { diff --git a/integration-tests/security-webauthn/src/main/resources/META-INF/resources/index.html b/integration-tests/security-webauthn/src/main/resources/META-INF/resources/index.html index 9446fd7df2d2f..293743f5c0257 100644 --- a/integration-tests/security-webauthn/src/main/resources/META-INF/resources/index.html +++ b/integration-tests/security-webauthn/src/main/resources/META-INF/resources/index.html @@ -57,14 +57,14 @@

    Status

    Login

    -
    +

    Register

    -
    +


    @@ -87,11 +87,11 @@

    Register

    const loginButton = document.getElementById('login'); loginButton.onclick = () => { - var userName = document.getElementById('userNameLogin').value; + var username = document.getElementById('usernameLogin').value; result.replaceChildren(); - webAuthn.login({ name: userName }) + webAuthn.login({ name: username }) .then(body => { - result.append("User: "+userName); + result.append("User: "+username); }) .catch(err => { result.append("Login failed: "+err); @@ -102,13 +102,13 @@

    Register

    const registerButton = document.getElementById('register'); registerButton.onclick = () => { - var userName = document.getElementById('userNameRegister').value; + var username = document.getElementById('usernameRegister').value; var firstName = document.getElementById('firstName').value; var lastName = document.getElementById('lastName').value; result.replaceChildren(); - webAuthn.register({ name: userName, displayName: firstName + " " + lastName }) + webAuthn.register({ name: username, displayName: firstName + " " + lastName }) .then(body => { - result.append("User: "+userName); + result.append("User: "+username); }) .catch(err => { result.append("Registration failed: "+err); diff --git a/integration-tests/security-webauthn/src/main/resources/application.properties b/integration-tests/security-webauthn/src/main/resources/application.properties index 06af79d80af21..8c351a0f75197 100644 --- a/integration-tests/security-webauthn/src/main/resources/application.properties +++ b/integration-tests/security-webauthn/src/main/resources/application.properties @@ -6,3 +6,5 @@ quarkus.hibernate-orm.database.generation=drop-and-create quarkus.webauthn.login-page=/ +quarkus.webauthn.enable-login-endpoint=true +quarkus.webauthn.enable-registration-endpoint=true diff --git a/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java b/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java index 44171258e4675..ad550bb3b4919 100644 --- a/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java +++ b/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java @@ -2,11 +2,13 @@ import static io.restassured.RestAssured.given; +import java.net.URL; import java.util.function.Consumer; import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; @@ -28,6 +30,9 @@ enum Endpoint { MANUAL; } + @TestHTTPResource + URL url; + @Test public void testWebAuthnUser() { testWebAuthn("FroMage", User.USER, Endpoint.DEFAULT); @@ -39,26 +44,26 @@ public void testWebAuthnAdmin() { testWebAuthn("admin", User.ADMIN, Endpoint.DEFAULT); } - private void testWebAuthn(String userName, User user, Endpoint endpoint) { + private void testWebAuthn(String username, User user, Endpoint endpoint) { Filter cookieFilter = new RenardeCookieFilter(); - WebAuthnHardware token = new WebAuthnHardware(); + WebAuthnHardware token = new WebAuthnHardware(url); verifyLoggedOut(cookieFilter); // two-step registration - String challenge = WebAuthnEndpointHelper.invokeRegistration(userName, cookieFilter); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge(username, cookieFilter); JsonObject registrationJson = token.makeRegistrationJson(challenge); if (endpoint == Endpoint.DEFAULT) - WebAuthnEndpointHelper.invokeCallback(registrationJson, cookieFilter); + WebAuthnEndpointHelper.invokeRegistration(username, registrationJson, cookieFilter); else { invokeCustomEndpoint("/register", cookieFilter, request -> { WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registrationJson); - request.formParam("userName", userName); + request.formParam("username", username); }); } // verify that we can access logged-in endpoints - verifyLoggedIn(cookieFilter, userName, user); + verifyLoggedIn(cookieFilter, username, user); // logout WebAuthnEndpointHelper.invokeLogout(cookieFilter); @@ -66,19 +71,19 @@ private void testWebAuthn(String userName, User user, Endpoint endpoint) { verifyLoggedOut(cookieFilter); // two-step login - challenge = WebAuthnEndpointHelper.invokeLogin(userName, cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge(username, cookieFilter); JsonObject loginJson = token.makeLoginJson(challenge); if (endpoint == Endpoint.DEFAULT) - WebAuthnEndpointHelper.invokeCallback(loginJson, cookieFilter); + WebAuthnEndpointHelper.invokeLogin(loginJson, cookieFilter); else { invokeCustomEndpoint("/login", cookieFilter, request -> { WebAuthnEndpointHelper.addWebAuthnLoginFormParameters(request, loginJson); - request.formParam("userName", userName); + request.formParam("username", username); }); } // verify that we can access logged-in endpoints - verifyLoggedIn(cookieFilter, userName, user); + verifyLoggedIn(cookieFilter, username, user); // logout WebAuthnEndpointHelper.invokeLogout(cookieFilter); @@ -96,14 +101,13 @@ private void invokeCustomEndpoint(String uri, Filter cookieFilter, Consumer { @Query(value = "SELECT b.publicationYear FROM Book b where b.bid = :bid") Integer customFindPublicationYearObject(@Param("bid") Integer bid); + // Related to issue 41292 + public default Page findPaged(Pageable pageable) { + List list = findAll(); + return new PageImpl<>(list, pageable, list.size()); + } + interface BookCountByYear { int getPublicationYear(); diff --git a/integration-tests/spring-data-jpa/src/main/java/io/quarkus/it/spring/data/jpa/BookResource.java b/integration-tests/spring-data-jpa/src/main/java/io/quarkus/it/spring/data/jpa/BookResource.java index 55e6342d340e3..2db6fcd4d5f73 100644 --- a/integration-tests/spring-data-jpa/src/main/java/io/quarkus/it/spring/data/jpa/BookResource.java +++ b/integration-tests/spring-data-jpa/src/main/java/io/quarkus/it/spring/data/jpa/BookResource.java @@ -8,8 +8,12 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Response; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + @Path("/book") public class BookResource { @@ -115,4 +119,10 @@ public Integer customFindPublicationYearObject(@PathParam("bid") Integer bid) { return bookRepository.customFindPublicationYearObject(bid); } + @GET + @Path("/paged") + public Page getPaged(@QueryParam("size") int size, @QueryParam("page") int page) { + return bookRepository.findPaged(PageRequest.of(page, size)); + } + } diff --git a/integration-tests/spring-data-jpa/src/test/java/io/quarkus/it/spring/data/jpa/BookResourceTest.java b/integration-tests/spring-data-jpa/src/test/java/io/quarkus/it/spring/data/jpa/BookResourceTest.java index 3bd490e6594ee..318cd12659c13 100644 --- a/integration-tests/spring-data-jpa/src/test/java/io/quarkus/it/spring/data/jpa/BookResourceTest.java +++ b/integration-tests/spring-data-jpa/src/test/java/io/quarkus/it/spring/data/jpa/BookResourceTest.java @@ -1,13 +1,17 @@ package io.quarkus.it.spring.data.jpa; +import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.when; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.core.Is.is; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import io.quarkus.test.junit.QuarkusTest; +import io.restassured.response.Response; @QuarkusTest public class BookResourceTest { @@ -132,4 +136,16 @@ void testCustomFindPublicationYearObject() { .statusCode(200) .body(is("2011")); } + + @Test + void testEnsureFieldPageableIsSerialized() { + Response response = given() + .accept("application/json") + .queryParam("size", "2") + .queryParam("page", "1") + .when().get("/book/paged"); + Assertions.assertEquals(200, response.statusCode()); + assertThat(response.body().jsonPath().getString("pageable")).contains("paged:true"); + assertThat(response.body().jsonPath().getString("pageable")).contains("unpaged:false"); + } } diff --git a/integration-tests/test-extension/extension/deployment/src/test/java/io/quarkus/deployment/pkg/builditem/GeneratedResourceBuildItemTest.java b/integration-tests/test-extension/extension/deployment/src/test/java/io/quarkus/deployment/pkg/builditem/GeneratedResourceBuildItemTest.java index 4a2ff352966e7..85c75b637ca23 100644 --- a/integration-tests/test-extension/extension/deployment/src/test/java/io/quarkus/deployment/pkg/builditem/GeneratedResourceBuildItemTest.java +++ b/integration-tests/test-extension/extension/deployment/src/test/java/io/quarkus/deployment/pkg/builditem/GeneratedResourceBuildItemTest.java @@ -6,11 +6,14 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.net.InetAddress; import java.net.URL; +import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -35,7 +38,8 @@ class GeneratedResourceBuildItemTest { Dependency.of("org.apache.cxf", "cxf-rt-bindings-soap", "3.4.3"))); @Test - public void testXMLResourceWasMerged() throws IOException { + public void testXMLResourceWasMerged() { + Assumptions.assumeTrue(isOnline()); assertThat(runner.getStartupConsoleOutput()).contains("RESOURCES: 1", "org.apache.cxf.binding.xml.wsdl11.HttpAddressPlugin", "org.apache.cxf.binding.xml.wsdl11.XmlBindingPlugin", @@ -58,4 +62,13 @@ public static void main(String[] args) throws IOException { } } } + + boolean isOnline() { + try { + InetAddress resolved = InetAddress.getByName("sun.com"); + return resolved != null; + } catch (UnknownHostException e) { + return false; + } + } } diff --git a/integration-tests/test-extension/tests/src/main/java/io/quarkus/it/testsupport/commandmode/CdiBean.java b/integration-tests/test-extension/tests/src/main/java/io/quarkus/it/testsupport/commandmode/CdiBean.java new file mode 100644 index 0000000000000..610105ab90ff8 --- /dev/null +++ b/integration-tests/test-extension/tests/src/main/java/io/quarkus/it/testsupport/commandmode/CdiBean.java @@ -0,0 +1,5 @@ +package io.quarkus.it.testsupport.commandmode; + +public interface CdiBean { + String myMethod(); +} \ No newline at end of file diff --git a/integration-tests/test-extension/tests/src/main/java/io/quarkus/it/testsupport/commandmode/DefaultBean.java b/integration-tests/test-extension/tests/src/main/java/io/quarkus/it/testsupport/commandmode/DefaultBean.java new file mode 100644 index 0000000000000..0ffdc6de16578 --- /dev/null +++ b/integration-tests/test-extension/tests/src/main/java/io/quarkus/it/testsupport/commandmode/DefaultBean.java @@ -0,0 +1,11 @@ +package io.quarkus.it.testsupport.commandmode; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class DefaultBean implements CdiBean { + @Override + public String myMethod() { + return "default bean"; + } +} diff --git a/integration-tests/test-extension/tests/src/main/java/io/quarkus/it/testsupport/commandmode/MainApp.java b/integration-tests/test-extension/tests/src/main/java/io/quarkus/it/testsupport/commandmode/MainApp.java new file mode 100644 index 0000000000000..4557b5c5b033d --- /dev/null +++ b/integration-tests/test-extension/tests/src/main/java/io/quarkus/it/testsupport/commandmode/MainApp.java @@ -0,0 +1,23 @@ +package io.quarkus.it.testsupport.commandmode; + +import jakarta.inject.Inject; + +import io.quarkus.runtime.QuarkusApplication; +import io.quarkus.runtime.annotations.QuarkusMain; + +/* + * Because this app co-exists in a module with a QuarkusIntegrationTest, it needs to not be on the default path. + * Otherwise, this application is executed by the QuarkusIntegrationTest and exits early, causing test failures elsewhere. + */ +@QuarkusMain(name = "test") +public class MainApp implements QuarkusApplication { + + @Inject + CdiBean myBean; + + @Override + public int run(String... args) throws Exception { + System.out.println("The bean is " + myBean.myMethod()); + return 0; + } +} \ No newline at end of file diff --git a/integration-tests/test-extension/tests/src/test/java/io/quarkus/it/extension/ClasspathTestCase.java b/integration-tests/test-extension/tests/src/test/java/io/quarkus/it/extension/ClasspathTestCase.java index 3a8efbf1b22f1..29fed1e8a63f1 100644 --- a/integration-tests/test-extension/tests/src/test/java/io/quarkus/it/extension/ClasspathTestCase.java +++ b/integration-tests/test-extension/tests/src/test/java/io/quarkus/it/extension/ClasspathTestCase.java @@ -3,7 +3,6 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.is; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import io.quarkus.test.junit.DisabledOnIntegrationTest; @@ -53,9 +52,10 @@ public void testStaticInitMainResourceNoDuplicate() { .body(is("OK")); } + // For some reason, class files are not accessible as resources through the runtime init classloader;" + // that's beside the point of this PR though, so we'll ignore that." @Test - @Disabled("For some reason, class files are not accessible as resources through the runtime init classloader;" - + " that's beside the point of this PR though, so we'll ignore that.") + @DisabledOnIntegrationTest() public void testRuntimeInitMainClassNoDuplicate() { given().param("resourceName", CLASS_FILE) .param("phase", "runtime_init") @@ -65,6 +65,8 @@ public void testRuntimeInitMainClassNoDuplicate() { @Test public void testRuntimeInitMainResourceNoDuplicate() { + // Runtime classloader classes are stored in memory, as "quarkus:" resources, and we do not have a quarkus filesystem provider + // at the moment, the path helper works around that by hacking/reverse engineering a file-based location given().param("resourceName", RESOURCE_FILE) .param("phase", "runtime_init") .when().get("/core/classpath").then() diff --git a/integration-tests/test-extension/tests/src/test/java/io/quarkus/it/testsupport/commandmode/QuarkusMainTestWithTestProfileTestCase.java b/integration-tests/test-extension/tests/src/test/java/io/quarkus/it/testsupport/commandmode/QuarkusMainTestWithTestProfileTestCase.java new file mode 100644 index 0000000000000..1e69dc60e5012 --- /dev/null +++ b/integration-tests/test-extension/tests/src/test/java/io/quarkus/it/testsupport/commandmode/QuarkusMainTestWithTestProfileTestCase.java @@ -0,0 +1,52 @@ +package io.quarkus.it.testsupport.commandmode; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import java.util.Set; + +import jakarta.enterprise.inject.Alternative; +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.junit.main.Launch; +import io.quarkus.test.junit.main.LaunchResult; +import io.quarkus.test.junit.main.QuarkusMainTest; + +@QuarkusMainTest +@TestProfile(QuarkusMainTestWithTestProfileTestCase.MyTestProfile.class) +public class QuarkusMainTestWithTestProfileTestCase { + + @Test + @Launch(value = {}) + public void testLaunchCommand(LaunchResult result) { + assertThat(result.getOutput()) + .contains("The bean is mocked value"); + } + + public static class MyTestProfile implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { + return Map.of("quarkus.package.main-class", "test"); + } + + @Override + public Set> getEnabledAlternatives() { + return Set.of(MockedCdiBean.class); + } + } + + @Alternative + @Singleton + public static class MockedCdiBean implements CdiBean { + + @Override + public String myMethod() { + return "mocked value"; + } + } +} \ No newline at end of file diff --git a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-callback-from-extension/pom.xml b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-callback-from-extension/pom.xml index 1c7c3a0267d69..557c179842511 100644 --- a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-callback-from-extension/pom.xml +++ b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-callback-from-extension/pom.xml @@ -12,7 +12,7 @@ 17 UTF-8 UTF-8 - 3.5.0 + 3.5.2 @project.version@ diff --git a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-callback-from-extension/src/main/resources/application.properties b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-callback-from-extension/src/main/resources/application.properties index 8d698e657885b..442095ca8410c 100644 --- a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-callback-from-extension/src/main/resources/application.properties +++ b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-callback-from-extension/src/main/resources/application.properties @@ -1,3 +1 @@ -quarkus.test.continuous-testing=enabled -# this should not be needed, but something in the tests is setting this to 1234 and confusing the test framework, so set it here to match -quarkus.http.non-application-root-path=1234 \ No newline at end of file +quarkus.test.continuous-testing=enabled \ No newline at end of file diff --git a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-parameter-injection/pom.xml b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-parameter-injection/pom.xml index e3a571400982e..9cda97d707057 100644 --- a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-parameter-injection/pom.xml +++ b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-parameter-injection/pom.xml @@ -13,7 +13,7 @@ UTF-8 UTF-8 quarkus-bom - 3.5.0 + 3.5.2 @project.version@ diff --git a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-parameter-injection/src/main/resources/application.properties b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-parameter-injection/src/main/resources/application.properties index 8d698e657885b..442095ca8410c 100644 --- a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-parameter-injection/src/main/resources/application.properties +++ b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-parameter-injection/src/main/resources/application.properties @@ -1,3 +1 @@ -quarkus.test.continuous-testing=enabled -# this should not be needed, but something in the tests is setting this to 1234 and confusing the test framework, so set it here to match -quarkus.http.non-application-root-path=1234 \ No newline at end of file +quarkus.test.continuous-testing=enabled \ No newline at end of file diff --git a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension-with-bytecode-changes/pom.xml b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension-with-bytecode-changes/pom.xml index c0f6c1cf83887..b90cfe7e754cd 100644 --- a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension-with-bytecode-changes/pom.xml +++ b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension-with-bytecode-changes/pom.xml @@ -12,7 +12,7 @@ 17 UTF-8 UTF-8 - 3.5.0 + 3.5.2 @project.version@ diff --git a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension-with-bytecode-changes/src/main/resources/application.properties b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension-with-bytecode-changes/src/main/resources/application.properties index 8d698e657885b..442095ca8410c 100644 --- a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension-with-bytecode-changes/src/main/resources/application.properties +++ b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension-with-bytecode-changes/src/main/resources/application.properties @@ -1,3 +1 @@ -quarkus.test.continuous-testing=enabled -# this should not be needed, but something in the tests is setting this to 1234 and confusing the test framework, so set it here to match -quarkus.http.non-application-root-path=1234 \ No newline at end of file +quarkus.test.continuous-testing=enabled \ No newline at end of file diff --git a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension/pom.xml b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension/pom.xml index c0f6c1cf83887..b90cfe7e754cd 100644 --- a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension/pom.xml +++ b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension/pom.xml @@ -12,7 +12,7 @@ 17 UTF-8 UTF-8 - 3.5.0 + 3.5.2 @project.version@ diff --git a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension/src/main/resources/application.properties b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension/src/main/resources/application.properties index 8d698e657885b..442095ca8410c 100644 --- a/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension/src/main/resources/application.properties +++ b/integration-tests/test-extension/tests/src/test/resources-filtered/projects/project-using-test-template-from-extension/src/main/resources/application.properties @@ -1,3 +1 @@ -quarkus.test.continuous-testing=enabled -# this should not be needed, but something in the tests is setting this to 1234 and confusing the test framework, so set it here to match -quarkus.http.non-application-root-path=1234 \ No newline at end of file +quarkus.test.continuous-testing=enabled \ No newline at end of file diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/WebAuthnVirtualThreadTestUserProvider.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/WebAuthnVirtualThreadTestUserProvider.java index 7c7250eb12607..a277191bb61e8 100644 --- a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/WebAuthnVirtualThreadTestUserProvider.java +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/WebAuthnVirtualThreadTestUserProvider.java @@ -4,11 +4,11 @@ import jakarta.enterprise.context.ApplicationScoped; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; import io.quarkus.test.vertx.VirtualThreadsAssertions; import io.smallrye.common.annotation.RunOnVirtualThread; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; /** * This UserProvider stores and updates the credentials in the callback endpoint, but is blocking @@ -17,21 +17,27 @@ @RunOnVirtualThread public class WebAuthnVirtualThreadTestUserProvider extends WebAuthnTestUserProvider { @Override - public Uni> findWebAuthnCredentialsByCredID(String credId) { + public Uni findByCredentialId(String credId) { assertVirtualThread(); - return super.findWebAuthnCredentialsByCredID(credId); + return super.findByCredentialId(credId); } @Override - public Uni> findWebAuthnCredentialsByUserName(String userId) { + public Uni> findByUsername(String username) { assertVirtualThread(); - return super.findWebAuthnCredentialsByUserName(userId); + return super.findByUsername(username); } @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { + public Uni store(WebAuthnCredentialRecord credentialRecord) { assertVirtualThread(); - return super.updateOrStoreWebAuthnCredentials(authenticator); + return super.store(credentialRecord); + } + + @Override + public Uni update(String credentialId, long counter) { + assertVirtualThread(); + return super.update(credentialId, counter); } private void assertVirtualThread() { diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties index e69de29bb2d1d..6ef5d8c9ccb2e 100644 --- a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties @@ -0,0 +1,2 @@ +quarkus.webauthn.enable-login-endpoint=true +quarkus.webauthn.enable-registration-endpoint=true diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java index 6ff6f8303ec7c..711f4e5140444 100644 --- a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java @@ -2,9 +2,12 @@ import static io.quarkus.virtual.security.webauthn.RunOnVirtualThreadTest.checkLoggedIn; +import java.net.URL; + import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.junit.QuarkusIntegrationTest; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; @@ -15,6 +18,9 @@ @QuarkusIntegrationTest class RunOnVirtualThreadIT { + @TestHTTPResource + URL url; + @Test public void test() { @@ -30,12 +36,12 @@ public void test() { .get("/cheese").then().statusCode(302); CookieFilter cookieFilter = new CookieFilter(); - WebAuthnHardware hardwareKey = new WebAuthnHardware(); - String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter); JsonObject registration = hardwareKey.makeRegistrationJson(challenge); // now finalise - WebAuthnEndpointHelper.invokeCallback(registration, cookieFilter); + WebAuthnEndpointHelper.invokeRegistration("stef", registration, cookieFilter); // make sure our login cookie works checkLoggedIn(cookieFilter); @@ -43,11 +49,11 @@ public void test() { // reset cookies for the login phase cookieFilter = new CookieFilter(); // now try to log in - challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", cookieFilter); JsonObject login = hardwareKey.makeLoginJson(challenge); // now finalise - WebAuthnEndpointHelper.invokeCallback(login, cookieFilter); + WebAuthnEndpointHelper.invokeLogin(login, cookieFilter); // make sure our login cookie still works checkLoggedIn(cookieFilter); diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java index 4d73fc4210d59..145e64ad43d16 100644 --- a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java @@ -1,7 +1,6 @@ package io.quarkus.virtual.security.webauthn; -import static org.hamcrest.Matchers.is; - +import java.net.URL; import java.util.List; import jakarta.inject.Inject; @@ -10,7 +9,9 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit5.virtual.ShouldNotPin; import io.quarkus.test.junit5.virtual.VirtualThreadUnit; @@ -19,7 +20,6 @@ import io.restassured.RestAssured; import io.restassured.filter.cookie.CookieFilter; import io.vertx.core.json.JsonObject; -import io.vertx.ext.auth.webauthn.Authenticator; @QuarkusTest @VirtualThreadUnit @@ -29,6 +29,9 @@ class RunOnVirtualThreadTest { @Inject WebAuthnUserProvider userProvider; + @TestHTTPResource + URL url; + @Test public void test() throws Exception { @@ -43,19 +46,19 @@ public void test() throws Exception { .given().redirects().follow(false) .get("/cheese").then().statusCode(302); - Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely().isEmpty()); + Assertions.assertTrue(userProvider.findByUsername("stef").await().indefinitely().isEmpty()); CookieFilter cookieFilter = new CookieFilter(); - WebAuthnHardware hardwareKey = new WebAuthnHardware(); - String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter); JsonObject registration = hardwareKey.makeRegistrationJson(challenge); // now finalise - WebAuthnEndpointHelper.invokeCallback(registration, cookieFilter); + WebAuthnEndpointHelper.invokeRegistration("stef", registration, cookieFilter); // make sure we stored the user - List users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + List users = userProvider.findByUsername("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); - Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertTrue(users.get(0).getUsername().equals("stef")); Assertions.assertEquals(1, users.get(0).getCounter()); // make sure our login cookie works @@ -64,16 +67,16 @@ public void test() throws Exception { // reset cookies for the login phase cookieFilter = new CookieFilter(); // now try to log in - challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", cookieFilter); JsonObject login = hardwareKey.makeLoginJson(challenge); // now finalise - WebAuthnEndpointHelper.invokeCallback(login, cookieFilter); + WebAuthnEndpointHelper.invokeLogin(login, cookieFilter); // make sure we bumped the user - users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + users = userProvider.findByUsername("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); - Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertTrue(users.get(0).getUsername().equals("stef")); Assertions.assertEquals(2, users.get(0).getCounter()); // make sure our login cookie still works diff --git a/pom.xml b/pom.xml index 1c4bd24f8598e..f4198885401f4 100644 --- a/pom.xml +++ b/pom.xml @@ -55,7 +55,7 @@ jdbc:postgresql:hibernate_orm_test 4.5.4 - 0.0.117 + 0.0.118 false false @@ -76,11 +76,11 @@ 1.14.18 7.0.3.Final 2.4.2.Final - 8.0.1.Final + 8.0.2.Final 7.2.2.Final - 1.68.2 + 1.69.0 1.2.2 3.25.5 ${protoc.version} diff --git a/test-framework/common/src/main/java/io/quarkus/test/ActivateSessionContext.java b/test-framework/common/src/main/java/io/quarkus/test/ActivateSessionContext.java new file mode 100644 index 0000000000000..3acd8b00525af --- /dev/null +++ b/test-framework/common/src/main/java/io/quarkus/test/ActivateSessionContext.java @@ -0,0 +1,30 @@ +package io.quarkus.test; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.concurrent.CompletionStage; + +import jakarta.interceptor.InterceptorBinding; + +/** + * Activates the session context before the intercepted method is called, and terminates the context when the method invocation + * completes (regardless of any exceptions being thrown). + *

    + * If the context is already active, it's a noop - the context is neither activated nor deactivated. + *

    + * Keep in mind that if the method returns an asynchronous type (such as {@link CompletionStage} then the session context is + * still terminated when the invocation completes and not at the time the asynchronous type is completed. Also note that session + * context is not propagated by MicroProfile Context Propagation. + *

    + * This interceptor binding is only available in tests. + */ +@InterceptorBinding +@Target({ METHOD, TYPE }) +@Retention(RUNTIME) +public @interface ActivateSessionContext { + +} diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/LauncherUtil.java b/test-framework/common/src/main/java/io/quarkus/test/common/LauncherUtil.java index 22a4ed3fcca75..46586ca9b0178 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/common/LauncherUtil.java +++ b/test-framework/common/src/main/java/io/quarkus/test/common/LauncherUtil.java @@ -22,13 +22,10 @@ import java.util.regex.Pattern; import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; -import io.quarkus.runtime.LaunchMode; -import io.quarkus.runtime.configuration.ConfigUtils; -import io.quarkus.runtime.configuration.QuarkusConfigFactory; import io.quarkus.test.common.http.TestHTTPResourceManager; import io.quarkus.utilities.OS; -import io.smallrye.config.SmallRyeConfig; public final class LauncherUtil { @@ -37,10 +34,9 @@ public final class LauncherUtil { private LauncherUtil() { } + @Deprecated(forRemoval = true, since = "3.17") public static Config installAndGetSomeConfig() { - SmallRyeConfig config = ConfigUtils.configBuilder(false, LaunchMode.NORMAL).build(); - QuarkusConfigFactory.setConfig(config); - return config; + return ConfigProvider.getConfig(); } /** @@ -232,7 +228,6 @@ static void updateConfigForPort(Integer effectivePort) { if (effectivePort != null) { System.setProperty("quarkus.http.port", effectivePort.toString()); //set the port as a system property in order to have it applied to Config System.setProperty("quarkus.http.test-port", effectivePort.toString()); // needed for RestAssuredManager - installAndGetSomeConfig(); // reinitialize the configuration to make sure the actual port is used System.clearProperty("test.url"); // make sure the old value does not interfere with setting the new one System.setProperty("test.url", TestHTTPResourceManager.getUri()); } diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/TestConfigUtil.java b/test-framework/common/src/main/java/io/quarkus/test/common/TestConfigUtil.java index 8c6e128b28ad7..8b394c258958b 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/common/TestConfigUtil.java +++ b/test-framework/common/src/main/java/io/quarkus/test/common/TestConfigUtil.java @@ -18,6 +18,7 @@ public final class TestConfigUtil { private TestConfigUtil() { } + @Deprecated(forRemoval = true, since = "3.17") public static List argLineValue(Config config) { String strValue = config.getOptionalValue("quarkus.test.arg-line", String.class) .orElse(config.getOptionalValue("quarkus.test.argLine", String.class) // legacy value @@ -37,17 +38,20 @@ public static List argLineValue(Config config) { return result; } + @Deprecated(forRemoval = true, since = "3.17") public static Map env(Config config) { return ((SmallRyeConfig) config).getOptionalValues("quarkus.test.env", String.class, String.class) .orElse(Collections.emptyMap()); } + @Deprecated(forRemoval = true, since = "3.17") public static Duration waitTimeValue(Config config) { return config.getOptionalValue("quarkus.test.wait-time", Duration.class) .orElseGet(() -> config.getOptionalValue("quarkus.test.jar-wait-time", Duration.class) // legacy value .orElseGet(() -> Duration.ofSeconds(DEFAULT_WAIT_TIME_SECONDS))); } + @Deprecated(forRemoval = true, since = "3.17") public static String integrationTestProfile(Config config) { return config.getOptionalValue("quarkus.test.integration-test-profile", String.class) .orElseGet(() -> config.getOptionalValue("quarkus.test.native-image-profile", String.class) diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/TestResourceManager.java b/test-framework/common/src/main/java/io/quarkus/test/common/TestResourceManager.java index d5f606290bebe..b7dc8f2467de3 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/common/TestResourceManager.java +++ b/test-framework/common/src/main/java/io/quarkus/test/common/TestResourceManager.java @@ -38,8 +38,6 @@ import org.jboss.jandex.DotName; import org.jboss.jandex.IndexView; -import io.smallrye.config.SmallRyeConfigProviderResolver; - /** * Manages {@link QuarkusTestResourceLifecycleManager} */ @@ -53,6 +51,7 @@ public class TestResourceManager implements Closeable { private final List allTestResources; private final Map configProperties = new ConcurrentHashMap<>(); private final Set testResourceComparisonInfo; + private final DevServicesContext devServicesContext; private boolean started = false; @@ -117,7 +116,7 @@ public TestResourceManager(Class testClass, this.allTestResources = new ArrayList<>(sequentialTestResources); this.allTestResources.addAll(parallelTestResources); - DevServicesContext context = new DevServicesContext() { + this.devServicesContext = new DevServicesContext() { @Override public Map devServicesProperties() { return devServicesProperties; @@ -130,7 +129,7 @@ public Optional containerNetworkId() { }; for (var i : allTestResources) { if (i.getTestResource() instanceof DevServicesContext.ContextAware) { - ((DevServicesContext.ContextAware) i.getTestResource()).setIntegrationTestContext(context); + ((DevServicesContext.ContextAware) i.getTestResource()).setIntegrationTestContext(devServicesContext); } } } @@ -195,6 +194,7 @@ public Map start() { } public void inject(Object testInstance) { + injectTestContext(testInstance, devServicesContext); for (TestResourceStartInfo entry : allTestResources) { QuarkusTestResourceLifecycleManager quarkusTestResourceLifecycleManager = entry.getTestResource(); quarkusTestResourceLifecycleManager.inject(testInstance); @@ -202,6 +202,33 @@ public void inject(Object testInstance) { } } + private static void injectTestContext(Object testInstance, DevServicesContext context) { + Class c = testInstance.getClass(); + while (c != Object.class) { + for (Field f : c.getDeclaredFields()) { + if (f.getType().equals(DevServicesContext.class)) { + try { + f.setAccessible(true); + f.set(testInstance, context); + return; + } catch (Exception e) { + throw new RuntimeException("Unable to set field '" + f.getName() + + "' with the proper test context", e); + } + } else if (DevServicesContext.ContextAware.class.isAssignableFrom(f.getType())) { + f.setAccessible(true); + try { + DevServicesContext.ContextAware val = (DevServicesContext.ContextAware) f.get(testInstance); + val.setIntegrationTestContext(context); + } catch (Exception e) { + throw new RuntimeException("Unable to inject context into field " + f.getName(), e); + } + } + } + c = c.getSuperclass(); + } + } + public void close() { if (!started) { return; @@ -214,18 +241,6 @@ public void close() { throw new RuntimeException("Unable to stop Quarkus test resource " + entry.getTestResource(), e); } } - // TODO using QuarkusConfigFactory.setConfig(null) here makes continuous testing fail, - // e.g. in io.quarkus.hibernate.orm.HibernateHotReloadTestCase - // or io.quarkus.opentelemetry.deployment.OpenTelemetryContinuousTestingTest; - // maybe this cleanup is not really necessary and just "doesn't hurt" because - // the released config is still cached in QuarkusConfigFactory#config - // and will be restored soon after when QuarkusConfigFactory#getConfigFor is called? - // In that case we should remove this cleanup. - try { - ((SmallRyeConfigProviderResolver) SmallRyeConfigProviderResolver.instance()) - .releaseConfig(Thread.currentThread().getContextClassLoader()); - } catch (Throwable ignored) { - } configProperties.clear(); } diff --git a/test-framework/jacoco/deployment/pom.xml b/test-framework/jacoco/deployment/pom.xml index 7aa0f877e779a..21ac798e16142 100644 --- a/test-framework/jacoco/deployment/pom.xml +++ b/test-framework/jacoco/deployment/pom.xml @@ -52,9 +52,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/test-framework/jacoco/deployment/src/main/java/io/quarkus/jacoco/deployment/JacocoProcessor.java b/test-framework/jacoco/deployment/src/main/java/io/quarkus/jacoco/deployment/JacocoProcessor.java index e8f403b855ea7..7bc26c15ef928 100644 --- a/test-framework/jacoco/deployment/src/main/java/io/quarkus/jacoco/deployment/JacocoProcessor.java +++ b/test-framework/jacoco/deployment/src/main/java/io/quarkus/jacoco/deployment/JacocoProcessor.java @@ -56,15 +56,15 @@ void transformerBuildItem(BuildProducer transforme //no code coverage for continuous testing, it does not really make sense return; } - if (!config.enabled) { + if (!config.enabled()) { log.debug("quarkus-jacoco is disabled via config"); return; } - String dataFile = getFilePath(config.dataFile, outputTargetBuildItem.getOutputDirectory(), + String dataFile = getFilePath(config.dataFile(), outputTargetBuildItem.getOutputDirectory(), JacocoConfig.JACOCO_QUARKUS_EXEC); System.setProperty("jacoco-agent.destfile", dataFile); - if (!config.reuseDataFile) { + if (!config.reuseDataFile()) { Files.deleteIfExists(Paths.get(dataFile)); } @@ -97,15 +97,16 @@ public byte[] apply(String className, byte[] bytes) { }).build()); } } - if (config.report) { + if (config.report()) { ReportInfo info = new ReportInfo(); info.dataFile = dataFile; File targetdir = new File( - getFilePath(config.reportLocation, outputTargetBuildItem.getOutputDirectory(), JacocoConfig.JACOCO_REPORT)); + getFilePath(config.reportLocation(), outputTargetBuildItem.getOutputDirectory(), + JacocoConfig.JACOCO_REPORT)); info.reportDir = targetdir.getAbsolutePath(); - String includes = String.join(",", config.includes); - String excludes = String.join(",", config.excludes.orElse(Collections.emptyList())); + String includes = String.join(",", config.includes()); + String excludes = String.join(",", config.excludes().orElse(Collections.emptyList())); Set classes = new HashSet<>(); info.classFiles = classes; @@ -128,7 +129,7 @@ public byte[] apply(String className, byte[] bytes) { private void addProjectModule(ResolvedDependency module, JacocoConfig config, ReportInfo info, String includes, String excludes, Set classes, Set sources) throws Exception { - String dataFile = getFilePath(config.dataFile, module.getWorkspaceModule().getBuildDir().toPath(), + String dataFile = getFilePath(config.dataFile(), module.getWorkspaceModule().getBuildDir().toPath(), JacocoConfig.JACOCO_QUARKUS_EXEC); info.savedData.add(new File(dataFile).getAbsolutePath()); if (module.getSources() == null) { diff --git a/test-framework/jacoco/runtime/pom.xml b/test-framework/jacoco/runtime/pom.xml index 449c9951353b7..8a73f6019b40f 100644 --- a/test-framework/jacoco/runtime/pom.xml +++ b/test-framework/jacoco/runtime/pom.xml @@ -82,9 +82,6 @@ ${project.version} - - -AlegacyConfigRoot=true - diff --git a/test-framework/jacoco/runtime/src/main/java/io/quarkus/jacoco/runtime/JacocoConfig.java b/test-framework/jacoco/runtime/src/main/java/io/quarkus/jacoco/runtime/JacocoConfig.java index 5891ac704f6fa..f463cbd0dc003 100644 --- a/test-framework/jacoco/runtime/src/main/java/io/quarkus/jacoco/runtime/JacocoConfig.java +++ b/test-framework/jacoco/runtime/src/main/java/io/quarkus/jacoco/runtime/JacocoConfig.java @@ -4,68 +4,68 @@ import java.util.Optional; import io.quarkus.runtime.annotations.ConfigDocDefault; -import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; @ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) -public class JacocoConfig { +@ConfigMapping(prefix = "quarkus.jacoco") +public interface JacocoConfig { - public static final String JACOCO_QUARKUS_EXEC = "jacoco-quarkus.exec"; - public static final String JACOCO_REPORT = "jacoco-report"; - public static final String TARGET_JACOCO_QUARKUS_EXEC = "target/" + JACOCO_QUARKUS_EXEC; - public static final String TARGET_JACOCO_REPORT = "target/" + JACOCO_REPORT; + static final String JACOCO_QUARKUS_EXEC = "jacoco-quarkus.exec"; + static final String JACOCO_REPORT = "jacoco-report"; + static final String TARGET_JACOCO_QUARKUS_EXEC = "target/" + JACOCO_QUARKUS_EXEC; + static final String TARGET_JACOCO_REPORT = "target/" + JACOCO_REPORT; /** - * Whether or not the jacoco extension is enabled. + * Whether or not the Jacoco extension is enabled. */ - @ConfigItem(defaultValue = "true") - public boolean enabled; + @WithDefault("true") + boolean enabled(); /** - * The jacoco data file. + * The Jacoco data file. * The path can be relative (to the module) or absolute. */ - @ConfigItem @ConfigDocDefault(TARGET_JACOCO_QUARKUS_EXEC) - public Optional dataFile; + Optional dataFile(); /** - * Whether to reuse ({@code true}) or delete ({@code false}) the jacoco + * Whether to reuse ({@code true}) or delete ({@code false}) the Jacoco * data file on each run. */ - @ConfigItem(defaultValue = "false") - public boolean reuseDataFile; + @WithDefault("false") + boolean reuseDataFile(); /** * If Quarkus should generate the Jacoco report */ - @ConfigItem(defaultValue = "true") - public boolean report; + @WithDefault("true") + boolean report(); /** * Encoding of the generated reports. */ - @ConfigItem(defaultValue = "UTF-8") - public String outputEncoding; + @WithDefault("UTF-8") + String outputEncoding(); /** * Name of the root node HTML report pages. */ - @ConfigItem(defaultValue = "${quarkus.application.name}") - public Optional title; + @WithDefault("${quarkus.application.name}") + Optional title(); /** * Footer text used in HTML report pages. */ - @ConfigItem - public Optional footer; + public Optional footer(); /** * Encoding of the source files. */ - @ConfigItem(defaultValue = "UTF-8") - public String sourceEncoding; + @WithDefault("UTF-8") + public String sourceEncoding(); /** * A list of class files to include in the report. May use wildcard @@ -78,8 +78,8 @@ public class JacocoConfig { *

  • **/*BAR*.class targets classes that contain BAR in their name regardless of path
  • * */ - @ConfigItem(defaultValue = "**") - public List includes; + @WithDefault("**") + public List includes(); /** * A list of class files to exclude from the report. May use wildcard @@ -92,14 +92,12 @@ public class JacocoConfig { *
  • **/*BAR*.class targets classes that contain BAR in their name regardless of path
  • * */ - @ConfigItem - public Optional> excludes; + public Optional> excludes(); /** * The location of the report files. * The path can be relative (to the module) or absolute. */ - @ConfigItem @ConfigDocDefault(TARGET_JACOCO_REPORT) - public Optional reportLocation; + public Optional reportLocation(); } diff --git a/test-framework/jacoco/runtime/src/main/java/io/quarkus/jacoco/runtime/ReportCreator.java b/test-framework/jacoco/runtime/src/main/java/io/quarkus/jacoco/runtime/ReportCreator.java index 0a8351b0aab74..0e09043eae504 100644 --- a/test-framework/jacoco/runtime/src/main/java/io/quarkus/jacoco/runtime/ReportCreator.java +++ b/test-framework/jacoco/runtime/src/main/java/io/quarkus/jacoco/runtime/ReportCreator.java @@ -23,10 +23,9 @@ import org.jacoco.report.csv.CSVFormatter; import org.jacoco.report.html.HTMLFormatter; import org.jacoco.report.xml.XMLFormatter; -import org.jboss.logging.Logger; public class ReportCreator implements Runnable { - private static final Logger log = Logger.getLogger(ReportCreator.class); + private final ReportInfo reportInfo; private final JacocoConfig config; @@ -94,9 +93,9 @@ private void doRun() { } List formatters = new ArrayList<>(); - addXmlFormatter(new File(targetdir, "jacoco.xml"), config.outputEncoding, formatters); - addCsvFormatter(new File(targetdir, "jacoco.csv"), config.outputEncoding, formatters); - addHtmlFormatter(targetdir, config.outputEncoding, config.footer.orElse(""), Locale.getDefault(), + addXmlFormatter(new File(targetdir, "jacoco.xml"), config.outputEncoding(), formatters); + addCsvFormatter(new File(targetdir, "jacoco.csv"), config.outputEncoding(), formatters); + addHtmlFormatter(targetdir, config.outputEncoding(), config.footer().orElse(""), Locale.getDefault(), formatters); //now for the hacky bit @@ -106,9 +105,9 @@ private void doRun() { loader.getExecutionDataStore().getContents()); MultiSourceFileLocator sourceFileLocator = new MultiSourceFileLocator(4); for (String i : reportInfo.sourceDirectories) { - sourceFileLocator.add(new DirectorySourceFileLocator(new File(i), config.sourceEncoding, 4)); + sourceFileLocator.add(new DirectorySourceFileLocator(new File(i), config.sourceEncoding(), 4)); } - final IBundleCoverage bundle = builder.getBundle(config.title.orElse(reportInfo.artifactId)); + final IBundleCoverage bundle = builder.getBundle(config.title().orElse(reportInfo.artifactId)); visitor.visitBundle(bundle, sourceFileLocator); visitor.visitEnd(); System.out.println("Generated Jacoco reports in " + targetdir); diff --git a/test-framework/junit5-config/pom.xml b/test-framework/junit5-config/pom.xml new file mode 100644 index 0000000000000..9e5a21c6e54bb --- /dev/null +++ b/test-framework/junit5-config/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + + io.quarkus + quarkus-test-framework + 999-SNAPSHOT + + + quarkus-junit5-config + Quarkus - Test Framework - JUnit 5 Config + + + + org.junit.jupiter + junit-jupiter-api + + + io.smallrye.config + smallrye-config + + + io.quarkus + quarkus-core + + + io.quarkus + quarkus-core-deployment + + + + diff --git a/test-framework/junit5-config/src/main/java/io/quarkus/test/config/ConfigLauncherSession.java b/test-framework/junit5-config/src/main/java/io/quarkus/test/config/ConfigLauncherSession.java new file mode 100644 index 0000000000000..32a59806500a6 --- /dev/null +++ b/test-framework/junit5-config/src/main/java/io/quarkus/test/config/ConfigLauncherSession.java @@ -0,0 +1,28 @@ +package io.quarkus.test.config; + +import org.eclipse.microprofile.config.spi.ConfigProviderResolver; +import org.junit.platform.launcher.LauncherSession; +import org.junit.platform.launcher.LauncherSessionListener; + +import io.quarkus.runtime.LaunchMode; + +/** + * A JUnit {@link LauncherSessionListener}, used to register the initial test config. Test set up code can safely call + * ConfigProvider.getConfig() to retrieve an instance of the Quarkus configuration. + *

    + * The test config only contains sources known at bootstrap test time. For instance, config sources generated by + * Quarkus are not available in the test config. + */ +public class ConfigLauncherSession implements LauncherSessionListener { + @Override + public void launcherSessionOpened(final LauncherSession session) { + TestConfigProviderResolver resolver = new TestConfigProviderResolver(); + ConfigProviderResolver.setInstance(resolver); + resolver.getConfig(LaunchMode.TEST); + } + + @Override + public void launcherSessionClosed(final LauncherSession session) { + ((TestConfigProviderResolver) ConfigProviderResolver.instance()).restore(); + } +} diff --git a/test-framework/junit5-config/src/main/java/io/quarkus/test/config/LoggingSetupExtension.java b/test-framework/junit5-config/src/main/java/io/quarkus/test/config/LoggingSetupExtension.java new file mode 100644 index 0000000000000..b15c6f8f4e43f --- /dev/null +++ b/test-framework/junit5-config/src/main/java/io/quarkus/test/config/LoggingSetupExtension.java @@ -0,0 +1,17 @@ +package io.quarkus.test.config; + +import org.junit.jupiter.api.extension.Extension; + +import io.quarkus.runtime.logging.LoggingSetupRecorder; + +/** + * A global JUnit extension that enables/sets up basic logging if logging has not already been set up. + *

    + * This is useful for getting log output from non-Quarkus tests (if executed separately or before the first Quarkus + * test), but also for getting instant log output from {@code QuarkusTestResourceLifecycleManagers} etc. + */ +public class LoggingSetupExtension implements Extension { + public LoggingSetupExtension() { + LoggingSetupRecorder.handleFailedStart(); + } +} diff --git a/test-framework/junit5-config/src/main/java/io/quarkus/test/config/QuarkusClassOrderer.java b/test-framework/junit5-config/src/main/java/io/quarkus/test/config/QuarkusClassOrderer.java new file mode 100644 index 0000000000000..857c13a842548 --- /dev/null +++ b/test-framework/junit5-config/src/main/java/io/quarkus/test/config/QuarkusClassOrderer.java @@ -0,0 +1,41 @@ +package io.quarkus.test.config; + +import org.eclipse.microprofile.config.ConfigProvider; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.ClassOrdererContext; +import org.junit.platform.commons.util.ReflectionUtils; + +import io.quarkus.deployment.dev.testing.TestConfig; +import io.smallrye.config.SmallRyeConfig; + +/** + * A JUnit {@link ClassOrderer}, used to delegate to a custom implementations of {@link ClassOrderer} set by Quarkus + * config. + */ +public class QuarkusClassOrderer implements ClassOrderer { + private final ClassOrderer delegate; + + public QuarkusClassOrderer() { + SmallRyeConfig config = ConfigProvider.getConfig().unwrap(SmallRyeConfig.class); + TestConfig testConfig = config.getConfigMapping(TestConfig.class); + + delegate = testConfig.classOrderer() + .map(klass -> ReflectionUtils.tryToLoadClass(klass) + .andThenTry(ReflectionUtils::newInstance) + .andThenTry(instance -> (ClassOrderer) instance) + .toOptional().orElse(EMPTY)) + .orElse(EMPTY); + } + + @Override + public void orderClasses(final ClassOrdererContext context) { + delegate.orderClasses(context); + } + + private static final ClassOrderer EMPTY = new ClassOrderer() { + @Override + public void orderClasses(final ClassOrdererContext context) { + + } + }; +} diff --git a/test-framework/junit5-config/src/main/java/io/quarkus/test/config/TestConfigProviderResolver.java b/test-framework/junit5-config/src/main/java/io/quarkus/test/config/TestConfigProviderResolver.java new file mode 100644 index 0000000000000..9bda00bace7f1 --- /dev/null +++ b/test-framework/junit5-config/src/main/java/io/quarkus/test/config/TestConfigProviderResolver.java @@ -0,0 +1,99 @@ +package io.quarkus.test.config; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.spi.ConfigProviderResolver; + +import io.quarkus.deployment.dev.testing.TestConfig; +import io.quarkus.runtime.LaunchMode; +import io.quarkus.runtime.configuration.ConfigUtils; +import io.smallrye.config.SmallRyeConfig; +import io.smallrye.config.SmallRyeConfigBuilder; +import io.smallrye.config.SmallRyeConfigProviderResolver; + +/** + * A {@link org.eclipse.microprofile.config.spi.ConfigProviderResolver} to register {@link Config} in the Test + * classloader. + */ +public class TestConfigProviderResolver extends SmallRyeConfigProviderResolver { + private final SmallRyeConfigProviderResolver resolver; + private final ClassLoader classLoader; + private final Map configs; + + TestConfigProviderResolver() { + this.resolver = (SmallRyeConfigProviderResolver) SmallRyeConfigProviderResolver.instance(); + this.classLoader = Thread.currentThread().getContextClassLoader(); + this.configs = new ConcurrentHashMap<>(); + } + + @Override + public Config getConfig() { + return resolver.getConfig(); + } + + /** + * Registers a config in the Test classloader, by {@link LaunchMode}. Required for tests that launch Quarkus in + * Dev mode (which uses the dev config profile, instead of test. + *

    + * Retrieving the {@link Config} in a {@link LaunchMode} other than {@link LaunchMode#TEST}, must call + * {@link TestConfigProviderResolver#restoreConfig()} after using the config, to avoid mismatches in the config + * profile through the stack. + * + * @param mode the {@link LaunchMode} + * @return the registed {@link Config} instance + */ + public Config getConfig(final LaunchMode mode) { + if (classLoader.equals(Thread.currentThread().getContextClassLoader())) { + resolver.releaseConfig(classLoader); + SmallRyeConfig config = configs.computeIfAbsent(mode, new Function() { + @Override + public SmallRyeConfig apply(final LaunchMode launchMode) { + return ConfigUtils.configBuilder(false, true, mode) + .withProfile(mode.getDefaultProfile()) + .withMapping(TestConfig.class, "quarkus.test") + .build(); + } + }); + resolver.registerConfig(config, classLoader); + return config; + } + throw new IllegalStateException(); + } + + public void restoreConfig() { + if (classLoader.equals(Thread.currentThread().getContextClassLoader())) { + resolver.releaseConfig(classLoader); + resolver.registerConfig(configs.get(LaunchMode.TEST), classLoader); + } else { + throw new IllegalStateException(); + } + } + + public void restore() { + this.configs.clear(); + ConfigProviderResolver.setInstance(resolver); + } + + @Override + public Config getConfig(final ClassLoader loader) { + return resolver.getConfig(loader); + } + + @Override + public SmallRyeConfigBuilder getBuilder() { + return resolver.getBuilder(); + } + + @Override + public void registerConfig(final Config config, final ClassLoader classLoader) { + resolver.registerConfig(config, classLoader); + } + + @Override + public void releaseConfig(final Config config) { + resolver.releaseConfig(config); + } +} diff --git a/test-framework/junit5-config/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/test-framework/junit5-config/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 0000000000000..a91b69862a876 --- /dev/null +++ b/test-framework/junit5-config/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +io.quarkus.test.config.LoggingSetupExtension diff --git a/test-framework/junit5-config/src/main/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener b/test-framework/junit5-config/src/main/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener new file mode 100644 index 0000000000000..ca3c61a72cde2 --- /dev/null +++ b/test-framework/junit5-config/src/main/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener @@ -0,0 +1 @@ +io.quarkus.test.config.ConfigLauncherSession diff --git a/test-framework/junit5-config/src/main/resources/junit-platform.properties b/test-framework/junit5-config/src/main/resources/junit-platform.properties new file mode 100644 index 0000000000000..d24c3ed5820f5 --- /dev/null +++ b/test-framework/junit5-config/src/main/resources/junit-platform.properties @@ -0,0 +1,2 @@ +junit.jupiter.extensions.autodetection.enabled=true +junit.jupiter.testclass.order.default=io.quarkus.test.config.QuarkusClassOrderer diff --git a/test-framework/junit5-internal/pom.xml b/test-framework/junit5-internal/pom.xml index 526869cab070b..9bd5cf5b47f47 100644 --- a/test-framework/junit5-internal/pom.xml +++ b/test-framework/junit5-internal/pom.xml @@ -39,6 +39,10 @@ junit-jupiter-engine compile + + io.quarkus + quarkus-junit5-config + io.quarkus quarkus-core diff --git a/test-framework/junit5-internal/src/main/java/io/quarkus/test/DevModeTestApplicationModel.java b/test-framework/junit5-internal/src/main/java/io/quarkus/test/DevModeTestApplicationModel.java index 5864ff6f5fa68..ba8633abac8b1 100644 --- a/test-framework/junit5-internal/src/main/java/io/quarkus/test/DevModeTestApplicationModel.java +++ b/test-framework/junit5-internal/src/main/java/io/quarkus/test/DevModeTestApplicationModel.java @@ -41,7 +41,7 @@ public Iterable getDependencies(int flags) { } @Override - public Iterable getDependenciesWithAnyFlag(int... flags) { + public Iterable getDependenciesWithAnyFlag(int flags) { return delegate.getDependenciesWithAnyFlag(flags); } diff --git a/test-framework/junit5-internal/src/main/java/io/quarkus/test/QuarkusDevModeTest.java b/test-framework/junit5-internal/src/main/java/io/quarkus/test/QuarkusDevModeTest.java index d99d553734bfb..f9b6014cd5004 100644 --- a/test-framework/junit5-internal/src/main/java/io/quarkus/test/QuarkusDevModeTest.java +++ b/test-framework/junit5-internal/src/main/java/io/quarkus/test/QuarkusDevModeTest.java @@ -28,6 +28,7 @@ import java.util.logging.LogRecord; import java.util.stream.Stream; +import org.eclipse.microprofile.config.spi.ConfigProviderResolver; import org.jboss.logmanager.Logger; import org.jboss.shrinkwrap.api.ShrinkWrap; import org.jboss.shrinkwrap.api.exporter.ExplodedExporter; @@ -63,6 +64,7 @@ import io.quarkus.test.common.TestConfigUtil; import io.quarkus.test.common.TestResourceManager; import io.quarkus.test.common.http.TestHTTPResourceManager; +import io.quarkus.test.config.TestConfigProviderResolver; /** * A test extension for black-box testing of Quarkus development mode in extensions. @@ -234,11 +236,10 @@ public Object createTestInstance(TestInstanceFactoryContext factoryContext, Exte } @Override - public void beforeAll(ExtensionContext context) throws Exception { + public void beforeAll(ExtensionContext context) { + ((TestConfigProviderResolver) ConfigProviderResolver.instance()).getConfig(LaunchMode.DEVELOPMENT); TestConfigUtil.cleanUp(); GroovyClassValue.disable(); - //set the right launch mode in the outer CL, used by the HTTP host config source - LaunchMode.set(LaunchMode.DEVELOPMENT); originalRootLoggerHandlers = rootLogger.getHandlers(); rootLogger.addHandler(inMemoryLogHandler); } @@ -305,7 +306,7 @@ public void beforeEach(ExtensionContext extensionContext) throws Exception { } @Override - public void afterAll(ExtensionContext context) throws Exception { + public void afterAll(ExtensionContext context) { for (Map.Entry e : oldSystemProps.entrySet()) { if (e.getValue() == null) { System.clearProperty(e.getKey()); @@ -318,6 +319,7 @@ public void afterAll(ExtensionContext context) throws Exception { inMemoryLogHandler.setFilter(null); ClearCache.clearCaches(); TestConfigUtil.cleanUp(); + ((TestConfigProviderResolver) ConfigProviderResolver.instance()).restoreConfig(); } @Override @@ -440,6 +442,7 @@ private static ApplicationModel resolveOriginalAppModel() { try { return BootstrapAppModelFactory.newInstance() .setTest(true) + .setDevMode(true) .setProjectRoot(Path.of("").normalize().toAbsolutePath()) .resolveAppModel() .getApplicationModel(); diff --git a/test-framework/junit5-properties/pom.xml b/test-framework/junit5-properties/pom.xml deleted file mode 100644 index 63fb5bf6fe370..0000000000000 --- a/test-framework/junit5-properties/pom.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - 4.0.0 - - - io.quarkus - quarkus-test-framework - 999-SNAPSHOT - - - quarkus-junit5-properties - Quarkus - Test Framework - JUnit 5 - Properties - - Contains junit-platform.properties in a "user-excludable" way - until https://github.com/junit-team/junit5/issues/2794 is available. - - - - - diff --git a/test-framework/junit5-properties/src/main/resources/junit-platform.properties b/test-framework/junit5-properties/src/main/resources/junit-platform.properties deleted file mode 100644 index cdac134076ffb..0000000000000 --- a/test-framework/junit5-properties/src/main/resources/junit-platform.properties +++ /dev/null @@ -1,2 +0,0 @@ -junit.jupiter.extensions.autodetection.enabled=true -junit.jupiter.testclass.order.default=io.quarkus.test.junit.util.QuarkusTestProfileAwareClassOrderer diff --git a/test-framework/junit5/pom.xml b/test-framework/junit5/pom.xml index 449f8fda37df5..c4cb92b72454d 100644 --- a/test-framework/junit5/pom.xml +++ b/test-framework/junit5/pom.xml @@ -37,7 +37,7 @@ io.quarkus - quarkus-junit5-properties + quarkus-junit5-config org.junit.jupiter diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/AbstractJvmQuarkusTestExtension.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/AbstractJvmQuarkusTestExtension.java index f8db488299016..5b73fbfaaaf2c 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/AbstractJvmQuarkusTestExtension.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/AbstractJvmQuarkusTestExtension.java @@ -12,6 +12,7 @@ import java.util.Collection; import java.util.Deque; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -20,6 +21,7 @@ import jakarta.enterprise.inject.Alternative; +import org.eclipse.microprofile.config.ConfigProvider; import org.jboss.jandex.Index; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.extension.ConditionEvaluationResult; @@ -38,11 +40,13 @@ import io.quarkus.bootstrap.workspace.SourceDir; import io.quarkus.bootstrap.workspace.WorkspaceModule; import io.quarkus.deployment.dev.testing.CurrentTestApplication; +import io.quarkus.deployment.dev.testing.TestConfig; import io.quarkus.paths.PathList; import io.quarkus.runtime.LaunchMode; import io.quarkus.test.common.PathTestHelper; import io.quarkus.test.common.RestorableSystemProperties; import io.quarkus.test.common.TestClassIndexer; +import io.smallrye.config.SmallRyeConfig; public class AbstractJvmQuarkusTestExtension extends AbstractQuarkusTestWithContextExtension implements ExecutionCondition { @@ -279,8 +283,10 @@ public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext con if (context.getTestInstance().isPresent()) { return ConditionEvaluationResult.enabled("Quarkus Test Profile tags only affect classes"); } - String tagsStr = System.getProperty("quarkus.test.profile.tags"); - if ((tagsStr == null) || tagsStr.isEmpty()) { + SmallRyeConfig config = ConfigProvider.getConfig().unwrap(SmallRyeConfig.class); + TestConfig testConfig = config.getConfigMapping(TestConfig.class); + Optional> tags = testConfig.profile().tags(); + if (tags.isEmpty() || tags.get().isEmpty()) { return ConditionEvaluationResult.enabled("No Quarkus Test Profile tags"); } Class testProfile = getQuarkusTestProfile(context); @@ -295,8 +301,7 @@ public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext con throw new RuntimeException(e); } Set testProfileTags = profileInstance.tags(); - String[] tags = tagsStr.split(","); - for (String tag : tags) { + for (String tag : tags.get()) { String trimmedTag = tag.trim(); if (testProfileTags.contains(trimmedTag)) { return ConditionEvaluationResult.enabled("Tag '" + trimmedTag + "' is present on '" + testProfile diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/BasicLoggingEnabler.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/BasicLoggingEnabler.java deleted file mode 100644 index 1edc18fe9c662..0000000000000 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/BasicLoggingEnabler.java +++ /dev/null @@ -1,146 +0,0 @@ -package io.quarkus.test.junit; - -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; - -import org.eclipse.microprofile.config.Config; -import org.eclipse.microprofile.config.spi.ConfigProviderResolver; -import org.junit.jupiter.api.extension.BeforeAllCallback; -import org.junit.jupiter.api.extension.ExtensionContext; - -import io.quarkus.bootstrap.logging.InitialConfigurator; -import io.quarkus.runtime.LaunchMode; -import io.quarkus.runtime.configuration.ConfigUtils; - -/** - * A (global) JUnit callback that enables/sets up basic logging if logging has not already been set up. - *

    - * This is useful for getting log output from non-Quarkus tests (if executed separately or before the first Quarkus test), - * but also for getting instant log output from {@code QuarkusTestResourceLifecycleManagers} etc. - *

    - * This callback can be disabled via {@link #CFGKEY_ENABLED} in {@code junit-platform.properties} or via system property. - */ -public class BasicLoggingEnabler implements BeforeAllCallback { - - private static final String CFGKEY_ENABLED = "junit.quarkus.enable-basic-logging"; - private static Boolean enabled; - - private static final CompletableFuture configFuture; - - // internal flag, not meant to be used like CFGKEY_ENABLED - private static final boolean VERBOSE = Boolean.getBoolean(BasicLoggingEnabler.class.getName() + ".verbose"); - private static final long staticInitStart; - - // to speed things up a little, eager async loading of the config that will be looked up in LoggingSetupRecorder - // downside: doesn't obey CFGKEY_ENABLED, but that should be bearable - static { - staticInitStart = VERBOSE ? System.currentTimeMillis() : 0; - // e.g. continuous testing has everything set up already (DELAYED_HANDLER is active) - if (!InitialConfigurator.DELAYED_HANDLER.isActivated() - // at least respect CFGKEY_ENABLED if set as system property - && Boolean.parseBoolean(System.getProperty(CFGKEY_ENABLED, "true"))) { - - configFuture = CompletableFuture.supplyAsync(BasicLoggingEnabler::buildConfig); - } else { - configFuture = CompletableFuture.completedFuture(null); - } - } - - @Override - public synchronized void beforeAll(ExtensionContext context) { - if (enabled == null) { - enabled = context.getConfigurationParameter(CFGKEY_ENABLED).map(Boolean::valueOf).orElse(Boolean.TRUE); - } - if (!enabled || InitialConfigurator.DELAYED_HANDLER.isActivated()) { - return; - } - - var beforeAllStart = VERBOSE ? System.currentTimeMillis() : 0; - if (VERBOSE) { - System.out.printf("BasicLoggingEnabler took %s ms from static init to start of beforeAll()%n", - beforeAllStart - staticInitStart); - } - - ////////////////////// - // get the test config - - Config testConfig; - try { - testConfig = configFuture.get(); - // highly unlikely, but things might have changed since the static block decided to _not_ load the config - if (testConfig == null) { - testConfig = buildConfig(); - } - } catch (Exception e) { - // don't be too noisy (don't log the stacktrace) - System.err.printf("BasicLoggingEnabler failed to retrieve config: %s%n", - e instanceof ExecutionException ? ((ExecutionException) e).getCause() : e); - if (VERBOSE) { - e.printStackTrace(); - } - return; - } - - /////////////////////////// - // register the test config - - var configProviderResolver = ConfigProviderResolver.instance(); - var tccl = Thread.currentThread().getContextClassLoader(); - Config configToRestore; - try { - configProviderResolver.registerConfig(testConfig, tccl); - configToRestore = null; - } catch (IllegalStateException e) { - if (VERBOSE) { - System.out.println("BasicLoggingEnabler is swapping config after " + e); - } - // a config is already registered, which can happen in rare cases, - // so remember it for later restore, release it and register the test config instead - configToRestore = configProviderResolver.getConfig(); - configProviderResolver.releaseConfig(configToRestore); - configProviderResolver.registerConfig(testConfig, tccl); - } - - /////////////////// - // activate logging - - try { - IntegrationTestUtil.activateLogging(); - } catch (RuntimeException e) { - // don't be too noisy (don't log the stacktrace by default) - System.err.println("BasicLoggingEnabler failed to enable basic logging: " + e); - if (VERBOSE) { - e.printStackTrace(); - } - } finally { - // release the config that was registered previously so that tests that try to register their own config - // don't fail with: - // "IllegalStateException: SRCFG00017: Configuration already registered for the given class loader" - // also, a possible recreation of basically the same config for a later test class will consume far less time - configProviderResolver.releaseConfig(testConfig); - // if another config was already registered, restore/re-register it now - if (configToRestore != null) { - configProviderResolver.registerConfig(configToRestore, tccl); - } - } - if (VERBOSE) { - System.out.printf("BasicLoggingEnabler took %s ms from start of beforeAll() to end%n", - System.currentTimeMillis() - beforeAllStart); - } - } - - private static Config buildConfig() { - // make sure to load ConfigSources with the proper LaunchMode in place - LaunchMode.set(LaunchMode.TEST); - // notes: - // - addDiscovered might seem a bit much, but this ensures that yaml files are loaded (if extension is around) - // - LaunchMode.NORMAL instead of TEST avoids failing on missing RuntimeOverrideConfigSource$$GeneratedMapHolder - var start = VERBOSE ? System.currentTimeMillis() : 0; - var testConfig = ConfigUtils.configBuilder(true, true, LaunchMode.NORMAL).build(); - if (VERBOSE) { - System.out.printf("BasicLoggingEnabler took %s ms to load config%n", System.currentTimeMillis() - start); - testConfig.getConfigSources().forEach(s -> System.out.println("BasicLoggingEnabler ConfigSource: " + s)); - } - return testConfig; - } -} diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/IntegrationTestUtil.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/IntegrationTestUtil.java index 41b99f9d82c99..2e0cadbf4f15e 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/IntegrationTestUtil.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/IntegrationTestUtil.java @@ -29,7 +29,7 @@ import jakarta.inject.Inject; import org.apache.commons.lang3.RandomStringUtils; -import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.jandex.Index; import org.junit.jupiter.api.extension.ExtensionContext; @@ -49,7 +49,6 @@ import io.quarkus.runtime.LaunchMode; import io.quarkus.runtime.logging.LoggingSetupRecorder; import io.quarkus.test.common.ArtifactLauncher; -import io.quarkus.test.common.LauncherUtil; import io.quarkus.test.common.PathTestHelper; import io.quarkus.test.common.TestClassIndexer; import io.quarkus.test.common.TestResourceManager; @@ -276,9 +275,8 @@ public void accept(String s, String s2) { } catch (Exception e) { // use the network the use has specified or else just generate one if none is configured - Config config = LauncherUtil.installAndGetSomeConfig(); - Optional networkIdOpt = config - .getOptionalValue("quarkus.test.container.network", String.class); + Optional networkIdOpt = ConfigProvider.getConfig().getOptionalValue("quarkus.test.container.network", + String.class); if (networkIdOpt.isPresent()) { networkId = networkIdOpt.get(); } else { diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusIntegrationTestExtension.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusIntegrationTestExtension.java index 84f460e27704e..3b81cdbc17ffd 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusIntegrationTestExtension.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusIntegrationTestExtension.java @@ -17,9 +17,7 @@ import java.io.Closeable; import java.io.File; -import java.lang.reflect.Field; import java.nio.file.Path; -import java.time.Duration; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -31,7 +29,7 @@ import java.util.function.Function; import java.util.stream.Collectors; -import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.AfterTestExecutionCallback; @@ -44,11 +42,11 @@ import io.quarkus.bootstrap.app.CuratedApplication; import io.quarkus.bootstrap.logging.InitialConfigurator; +import io.quarkus.deployment.dev.testing.TestConfig; import io.quarkus.runtime.logging.JBossVersion; import io.quarkus.runtime.test.TestHttpEndpointProvider; import io.quarkus.test.common.ArtifactLauncher; import io.quarkus.test.common.DevServicesContext; -import io.quarkus.test.common.LauncherUtil; import io.quarkus.test.common.PropertyTestUtil; import io.quarkus.test.common.RestAssuredURLManager; import io.quarkus.test.common.RunCommandLauncher; @@ -58,6 +56,7 @@ import io.quarkus.test.common.TestScopeManager; import io.quarkus.test.junit.callback.QuarkusTestMethodContext; import io.quarkus.test.junit.launcher.ArtifactLauncherProvider; +import io.smallrye.config.SmallRyeConfig; public class QuarkusIntegrationTestExtension extends AbstractQuarkusTestWithContextExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback, BeforeEachCallback, AfterEachCallback, @@ -193,10 +192,10 @@ private QuarkusTestExtensionState doProcessStart(Properties quarkusArtifactPrope String artifactType = getArtifactType(quarkusArtifactProperties); - Config config = LauncherUtil.installAndGetSomeConfig(); - String testProfile = TestConfigUtil.integrationTestProfile(config); + SmallRyeConfig config = ConfigProvider.getConfig().unwrap(SmallRyeConfig.class); + TestConfig testConfig = config.getConfigMapping(TestConfig.class); boolean isDockerLaunch = isContainer(artifactType) - || (isJar(artifactType) && "test-with-native-agent".equals(testProfile)); + || (isJar(artifactType) && "test-with-native-agent".equals(testConfig.integrationTestProfile())); ArtifactLauncher.InitContext.DevServicesLaunchResult devServicesLaunchResult = handleDevServices(context, isDockerLaunch); @@ -269,20 +268,19 @@ public void close() throws Throwable { }); additionalProperties.putAll(resourceManagerProps); - ArtifactLauncher launcher = null; + ArtifactLauncher launcher; String testHost = System.getProperty("quarkus.http.test-host"); if ((testHost != null) && !testHost.isEmpty()) { launcher = new TestHostLauncher(); } else { - Duration waitDuration = TestConfigUtil.waitTimeValue(config); String target = TestConfigUtil.runTarget(config); // try to execute a run command published by an extension if it exists. We do this so that extensions that have a custom run don't have to create any special artifact type launcher = RunCommandLauncher.tryLauncher(devServicesLaunchResult.getCuratedApplication().getQuarkusBootstrap(), - target, waitDuration); + target, testConfig.waitTime()); if (launcher == null) { ServiceLoader loader = ServiceLoader.load(ArtifactLauncherProvider.class); for (ArtifactLauncherProvider launcherProvider : loader) { - if (launcherProvider.supportsArtifactType(artifactType, testProfile)) { + if (launcherProvider.supportsArtifactType(artifactType, testConfig.integrationTestProfile())) { launcher = launcherProvider.create( new DefaultArtifactLauncherCreateContext(quarkusArtifactProperties, context, requiredTestClass, @@ -328,44 +326,9 @@ public void postProcessTestInstance(Object testInstance, ExtensionContext contex ensureStarted(context); if (!failedBoot) { doProcessTestInstance(testInstance, context); - injectTestContext(testInstance); } } - private void injectTestContext(Object testInstance) { - Class c = testInstance.getClass(); - while (c != Object.class) { - for (Field f : c.getDeclaredFields()) { - if (f.getType().equals(DevServicesContext.class)) { - try { - f.setAccessible(true); - f.set(testInstance, createTestContext()); - return; - } catch (Exception e) { - throw new RuntimeException("Unable to set field '" + f.getName() - + "' with the proper test context", e); - } - } else if (DevServicesContext.ContextAware.class.isAssignableFrom(f.getType())) { - f.setAccessible(true); - try { - DevServicesContext.ContextAware val = (DevServicesContext.ContextAware) f.get(testInstance); - val.setIntegrationTestContext(createTestContext()); - } catch (Exception e) { - throw new RuntimeException("Unable to inject context into field " + f.getName(), e); - } - } - } - c = c.getSuperclass(); - } - } - - private DevServicesContext createTestContext() { - Map devServicesPropsCopy = devServicesProps.isEmpty() ? Collections.emptyMap() - : Collections.unmodifiableMap(devServicesProps); - return new DefaultQuarkusIntegrationTestContext(devServicesPropsCopy, - containerNetworkId == null ? Optional.empty() : Optional.of(containerNetworkId)); - } - private void throwBootFailureException() { if (firstException != null) { Throwable throwable = firstException; @@ -378,7 +341,7 @@ private void throwBootFailureException() { private boolean isCallbacksEnabledForIntegrationTests() { return Optional.ofNullable(System.getProperty(ENABLED_CALLBACKS_PROPERTY)).map(Boolean::parseBoolean) - .or(() -> LauncherUtil.installAndGetSomeConfig().getOptionalValue(ENABLED_CALLBACKS_PROPERTY, Boolean.class)) + .or(() -> ConfigProvider.getConfig().getOptionalValue(ENABLED_CALLBACKS_PROPERTY, Boolean.class)) .orElse(false); } diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusMainIntegrationTestExtension.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusMainIntegrationTestExtension.java index a7dea2f0c0fc1..b2b3c9ae6dae7 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusMainIntegrationTestExtension.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusMainIntegrationTestExtension.java @@ -17,7 +17,7 @@ import java.util.Properties; import java.util.ServiceLoader; -import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; @@ -26,16 +26,16 @@ import org.junit.jupiter.api.extension.ParameterResolutionException; import org.junit.jupiter.api.extension.ParameterResolver; +import io.quarkus.deployment.dev.testing.TestConfig; import io.quarkus.runtime.logging.JBossVersion; import io.quarkus.test.common.ArtifactLauncher; -import io.quarkus.test.common.LauncherUtil; -import io.quarkus.test.common.TestConfigUtil; import io.quarkus.test.common.TestResourceManager; import io.quarkus.test.junit.launcher.ArtifactLauncherProvider; import io.quarkus.test.junit.main.Launch; import io.quarkus.test.junit.main.LaunchResult; import io.quarkus.test.junit.main.QuarkusMainLauncher; import io.quarkus.test.junit.util.CloseAdaptor; +import io.smallrye.config.SmallRyeConfig; public class QuarkusMainIntegrationTestExtension extends AbstractQuarkusTestWithContextExtension implements BeforeEachCallback, AfterEachCallback, ParameterResolver { @@ -157,13 +157,13 @@ private ArtifactLauncher.LaunchResult doProcessStart(ExtensionContext context, S testResourceManager.inject(context.getRequiredTestInstance()); - Config config = LauncherUtil.installAndGetSomeConfig(); - String testProfile = TestConfigUtil.integrationTestProfile(config); + SmallRyeConfig config = ConfigProvider.getConfig().unwrap(SmallRyeConfig.class); + TestConfig testConfig = config.getConfigMapping(TestConfig.class); ArtifactLauncher launcher = null; ServiceLoader loader = ServiceLoader.load(ArtifactLauncherProvider.class); for (ArtifactLauncherProvider launcherProvider : loader) { - if (launcherProvider.supportsArtifactType(artifactType, testProfile)) { + if (launcherProvider.supportsArtifactType(artifactType, testConfig.integrationTestProfile())) { launcher = launcherProvider.create( new DefaultArtifactLauncherCreateContext(quarkusArtifactProperties, context, requiredTestClass, devServicesLaunchResult)); diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusTestExtension.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusTestExtension.java index 97ee658d4e4c0..b3e366e56d849 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusTestExtension.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusTestExtension.java @@ -80,6 +80,7 @@ import io.quarkus.test.junit.callback.QuarkusTestMethodContext; import io.quarkus.test.junit.internal.DeepClone; import io.quarkus.test.junit.internal.NewSerializingDeepClone; +import io.smallrye.config.SmallRyeConfigProviderResolver; public class QuarkusTestExtension extends AbstractJvmQuarkusTestExtension implements BeforeEachCallback, BeforeTestExecutionCallback, AfterTestExecutionCallback, AfterEachCallback, @@ -244,7 +245,9 @@ public Thread newThread(Runnable r) { .orElse(Duration.of(10, ChronoUnit.MINUTES)); hangTaskKey = hangDetectionExecutor.schedule(hangDetectionTask, hangTimeout.toMillis(), TimeUnit.MILLISECONDS); } - ConfigProviderResolver.setInstance(new RunningAppConfigResolver(runningQuarkusApplication)); + ConfigProviderResolver.instance().registerConfig( + new RunningAppConfigResolver(runningQuarkusApplication).getConfig(), + runningQuarkusApplication.getClassLoader()); RestorableSystemProperties restorableSystemProperties = RestorableSystemProperties.setProperties( Collections.singletonMap("test.url", TestHTTPResourceManager.getUri(runningQuarkusApplication))); @@ -352,7 +355,6 @@ public void beforeTestExecution(ExtensionContext context) throws Exception { } } else { throwBootFailureException(); - return; } } @@ -386,7 +388,6 @@ public void beforeEach(ExtensionContext context) throws Exception { } } else { throwBootFailureException(); - return; } } @@ -1134,21 +1135,20 @@ protected void doClose() { } catch (Throwable e) { log.error("Failed to shutdown Quarkus", e); } finally { + ((SmallRyeConfigProviderResolver) ConfigProviderResolver.instance()) + .releaseConfig(runningQuarkusApplication.getClassLoader()); runningQuarkusApplication = null; Thread.currentThread().setContextClassLoader(old); - ConfigProviderResolver.setInstance(null); } } } class FailedCleanup implements ExtensionContext.Store.CloseableResource { - @Override public void close() { shutdownHangDetection(); firstException = null; failedBoot = false; - ConfigProviderResolver.setInstance(null); } } diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/RunningAppConfigResolver.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/RunningAppConfigResolver.java index e3ec91cd40b6a..54580344058a4 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/RunningAppConfigResolver.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/RunningAppConfigResolver.java @@ -44,7 +44,7 @@ public Iterable getConfigSources() { @Override public ConfigValue getConfigValue(final String propertyName) { - throw illegalStateException(); + return runningQuarkusApplication.getConfigValue(propertyName, ConfigValue.class).get(); } @Override diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/DockerContainerLauncherProvider.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/DockerContainerLauncherProvider.java index 319ebcbaed462..e475c7e4e28aa 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/DockerContainerLauncherProvider.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/DockerContainerLauncherProvider.java @@ -17,13 +17,14 @@ import java.util.OptionalInt; import java.util.ServiceLoader; +import org.eclipse.microprofile.config.ConfigProvider; + +import io.quarkus.deployment.dev.testing.TestConfig; import io.quarkus.deployment.images.ContainerImages; import io.quarkus.deployment.util.FileUtil; import io.quarkus.test.common.ArtifactLauncher; import io.quarkus.test.common.DefaultDockerContainerLauncher; import io.quarkus.test.common.DockerContainerArtifactLauncher; -import io.quarkus.test.common.LauncherUtil; -import io.quarkus.test.common.TestConfigUtil; import io.smallrye.config.SmallRyeConfig; public class DockerContainerLauncherProvider implements ArtifactLauncherProvider { @@ -38,6 +39,7 @@ public DockerContainerArtifactLauncher create(CreateContext context) { String containerImage = context.quarkusArtifactProperties().getProperty("metadata.container-image"); boolean pullRequired = Boolean .parseBoolean(context.quarkusArtifactProperties().getProperty("metadata.pull-required", "false")); + SmallRyeConfig config = ConfigProvider.getConfig().unwrap(SmallRyeConfig.class); if ((containerImage != null) && !containerImage.isEmpty()) { DockerContainerArtifactLauncher launcher; ServiceLoader loader = ServiceLoader.load(DockerContainerArtifactLauncher.class); @@ -47,7 +49,6 @@ public DockerContainerArtifactLauncher create(CreateContext context) { } else { launcher = new DefaultDockerContainerLauncher(); } - SmallRyeConfig config = (SmallRyeConfig) LauncherUtil.installAndGetSomeConfig(); launcherInit(context, launcher, config, containerImage, pullRequired, Optional.empty(), volumeMounts(config), Collections.emptyList()); return launcher; @@ -59,10 +60,8 @@ public DockerContainerArtifactLauncher create(CreateContext context) { // adding a volume mapping pointing to the build output directory, // and then instructing the java process to run the run jar, // along with the native image agent arguments and any other additional parameters. - SmallRyeConfig config = (SmallRyeConfig) LauncherUtil.installAndGetSomeConfig(); - String testProfile = TestConfigUtil.integrationTestProfile(config); - - if ("test-with-native-agent".equals(testProfile)) { + TestConfig testConfig = config.getConfigMapping(TestConfig.class); + if ("test-with-native-agent".equals(testConfig.integrationTestProfile())) { DockerContainerArtifactLauncher launcher = new DefaultDockerContainerLauncher(); Optional entryPoint = Optional.of("java"); Map volumeMounts = new HashMap<>(volumeMounts(config)); @@ -83,13 +82,14 @@ public DockerContainerArtifactLauncher create(CreateContext context) { private void launcherInit(CreateContext context, DockerContainerArtifactLauncher launcher, SmallRyeConfig config, String containerImage, boolean pullRequired, Optional entryPoint, Map volumeMounts, List programArgs) { + TestConfig testConfig = config.getConfigMapping(TestConfig.class); launcher.init(new DefaultDockerInitContext( config.getValue("quarkus.http.test-port", OptionalInt.class).orElse(DEFAULT_PORT), config.getValue("quarkus.http.test-ssl-port", OptionalInt.class).orElse(DEFAULT_HTTPS_PORT), - TestConfigUtil.waitTimeValue(config), - TestConfigUtil.integrationTestProfile(config), - TestConfigUtil.argLineValue(config), - TestConfigUtil.env(config), + testConfig.waitTime(), + testConfig.integrationTestProfile(), + testConfig.argLine().orElse(List.of()), + testConfig.env(), context.devServicesLaunchResult(), containerImage, pullRequired, diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/JarLauncherProvider.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/JarLauncherProvider.java index ef360c7500053..3a6c6104227cf 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/JarLauncherProvider.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/JarLauncherProvider.java @@ -12,13 +12,13 @@ import java.util.OptionalInt; import java.util.ServiceLoader; -import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; +import io.quarkus.deployment.dev.testing.TestConfig; import io.quarkus.test.common.ArtifactLauncher; import io.quarkus.test.common.DefaultJarLauncher; import io.quarkus.test.common.JarArtifactLauncher; -import io.quarkus.test.common.LauncherUtil; -import io.quarkus.test.common.TestConfigUtil; +import io.smallrye.config.SmallRyeConfig; public class JarLauncherProvider implements ArtifactLauncherProvider { @@ -40,14 +40,15 @@ public JarArtifactLauncher create(CreateContext context) { launcher = new DefaultJarLauncher(); } - Config config = LauncherUtil.installAndGetSomeConfig(); + SmallRyeConfig config = ConfigProvider.getConfig().unwrap(SmallRyeConfig.class); + TestConfig testConfig = config.getConfigMapping(TestConfig.class); launcher.init(new DefaultJarInitContext( config.getValue("quarkus.http.test-port", OptionalInt.class).orElse(DEFAULT_PORT), config.getValue("quarkus.http.test-ssl-port", OptionalInt.class).orElse(DEFAULT_HTTPS_PORT), - TestConfigUtil.waitTimeValue(config), - TestConfigUtil.integrationTestProfile(config), - TestConfigUtil.argLineValue(config), - TestConfigUtil.env(config), + testConfig.waitTime(), + testConfig.integrationTestProfile(), + testConfig.argLine().orElse(List.of()), + testConfig.env(), context.devServicesLaunchResult(), context.buildOutputDirectory().resolve(pathStr))); return launcher; diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/NativeImageLauncherProvider.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/NativeImageLauncherProvider.java index e33465517c0c2..a9b2e2138e2cc 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/NativeImageLauncherProvider.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/NativeImageLauncherProvider.java @@ -11,13 +11,13 @@ import java.util.OptionalInt; import java.util.ServiceLoader; -import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; +import io.quarkus.deployment.dev.testing.TestConfig; import io.quarkus.test.common.ArtifactLauncher; import io.quarkus.test.common.DefaultNativeImageLauncher; -import io.quarkus.test.common.LauncherUtil; import io.quarkus.test.common.NativeImageLauncher; -import io.quarkus.test.common.TestConfigUtil; +import io.smallrye.config.SmallRyeConfig; public class NativeImageLauncherProvider implements ArtifactLauncherProvider { @Override @@ -38,14 +38,15 @@ public NativeImageLauncher create(CreateContext context) { launcher = new DefaultNativeImageLauncher(); } - Config config = LauncherUtil.installAndGetSomeConfig(); + SmallRyeConfig config = ConfigProvider.getConfig().unwrap(SmallRyeConfig.class); + TestConfig testConfig = config.getConfigMapping(TestConfig.class); launcher.init(new NativeImageLauncherProvider.DefaultNativeImageInitContext( config.getValue("quarkus.http.test-port", OptionalInt.class).orElse(DEFAULT_PORT), config.getValue("quarkus.http.test-ssl-port", OptionalInt.class).orElse(DEFAULT_HTTPS_PORT), - TestConfigUtil.waitTimeValue(config), - TestConfigUtil.integrationTestProfile(config), - TestConfigUtil.argLineValue(config), - TestConfigUtil.env(config), + testConfig.waitTime(), + testConfig.nativeImageProfile(), + testConfig.argLine().orElse(List.of()), + testConfig.env(), context.devServicesLaunchResult(), System.getProperty("native.image.path"), config.getOptionalValue("quarkus.package.output-directory", String.class).orElse(null), diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/util/QuarkusTestProfileAwareClassOrderer.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/util/QuarkusTestProfileAwareClassOrderer.java index 9607d14b5f7d7..a3d13b02f0436 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/util/QuarkusTestProfileAwareClassOrderer.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/util/QuarkusTestProfileAwareClassOrderer.java @@ -6,6 +6,8 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; import org.junit.jupiter.api.ClassDescriptor; import org.junit.jupiter.api.ClassOrderer; import org.junit.jupiter.api.ClassOrdererContext; @@ -27,7 +29,7 @@ *

    * By default, Quarkus*Tests not using any profile come first, then classes using a profile (in groups) and then all other * non-Quarkus tests (e.g. plain unit tests).
    - * Quarkus*Tests with {@linkplain WithTestResource#restrictToAnnotatedClass()} or + * Quarkus*Tests with {@linkplain WithTestResource#scope() matching resources} or * {@linkplain QuarkusTestResource#restrictToAnnotatedClass() restricted} {@code QuarkusTestResource} come * after tests with profiles and Quarkus*Tests with only unrestricted resources are handled like tests without a profile (come * first). @@ -35,7 +37,7 @@ * Internally, ordering is based on prefixes that are prepended to a secondary order suffix (by default the fully qualified * name of the respective test class), with the fully qualified class name of the * {@link io.quarkus.test.junit.QuarkusTestProfile QuarkusTestProfile} as an infix (if present). - * The default prefixes are defined by {@code DEFAULT_ORDER_PREFIX_*} and can be overridden in {@code junit-platform.properties} + * The default prefixes are defined by {@code DEFAULT_ORDER_PREFIX_*} and can be overridden in {@code application.properties} * via {@code CFGKEY_ORDER_PREFIX_*}, e.g. non-Quarkus tests can be run first (not last) by setting * {@link #CFGKEY_ORDER_PREFIX_NON_QUARKUS_TEST} to {@code 10_}. *

    @@ -69,31 +71,57 @@ public class QuarkusTestProfileAwareClassOrderer implements ClassOrderer { static final String CFGKEY_SECONDARY_ORDERER = "junit.quarkus.orderer.secondary-orderer"; + private final String prefixQuarkusTest; + private final String prefixQuarkusTestWithProfile; + private final String prefixQuarkusTestWithRestrictedResource; + private final String prefixNonQuarkusTest; + private final Optional secondaryOrderer; + + public QuarkusTestProfileAwareClassOrderer() { + Config config = ConfigProvider.getConfig(); + this.prefixQuarkusTest = config.getOptionalValue(CFGKEY_ORDER_PREFIX_QUARKUS_TEST, String.class) + .orElse(DEFAULT_ORDER_PREFIX_QUARKUS_TEST); + this.prefixQuarkusTestWithProfile = config.getOptionalValue(CFGKEY_ORDER_PREFIX_QUARKUS_TEST_WITH_PROFILE, String.class) + .orElse(DEFAULT_ORDER_PREFIX_QUARKUS_TEST_WITH_PROFILE); + this.prefixQuarkusTestWithRestrictedResource = config + .getOptionalValue(CFGKEY_ORDER_PREFIX_QUARKUS_TEST_WITH_RESTRICTED_RES, String.class) + .orElse(DEFAULT_ORDER_PREFIX_QUARKUS_TEST_WITH_RESTRICTED_RES); + this.prefixNonQuarkusTest = config.getOptionalValue(CFGKEY_ORDER_PREFIX_NON_QUARKUS_TEST, String.class) + .orElse(DEFAULT_ORDER_PREFIX_NON_QUARKUS_TEST); + this.secondaryOrderer = config.getOptionalValue(CFGKEY_SECONDARY_ORDERER, String.class); + } + + QuarkusTestProfileAwareClassOrderer( + final String prefixQuarkusTest, + final String prefixQuarkusTestWithProfile, + final String prefixQuarkusTestWithRestrictedResource, + final String prefixNonQuarkusTest, + final Optional secondaryOrderer) { + this.prefixQuarkusTest = prefixQuarkusTest; + this.prefixQuarkusTestWithProfile = prefixQuarkusTestWithProfile; + this.prefixQuarkusTestWithRestrictedResource = prefixQuarkusTestWithRestrictedResource; + this.prefixNonQuarkusTest = prefixNonQuarkusTest; + this.secondaryOrderer = secondaryOrderer; + } + @Override public void orderClasses(ClassOrdererContext context) { // don't do anything if there is just one test class or the current order request is for @Nested tests if (context.getClassDescriptors().size() <= 1 || context.getClassDescriptors().get(0).isAnnotated(Nested.class)) { return; } - var prefixQuarkusTest = getConfigParam( - CFGKEY_ORDER_PREFIX_QUARKUS_TEST, - DEFAULT_ORDER_PREFIX_QUARKUS_TEST, - context); - var prefixQuarkusTestWithProfile = getConfigParam( - CFGKEY_ORDER_PREFIX_QUARKUS_TEST_WITH_PROFILE, - DEFAULT_ORDER_PREFIX_QUARKUS_TEST_WITH_PROFILE, - context); - var prefixQuarkusTestWithRestrictedResource = getConfigParam( - CFGKEY_ORDER_PREFIX_QUARKUS_TEST_WITH_RESTRICTED_RES, - DEFAULT_ORDER_PREFIX_QUARKUS_TEST_WITH_RESTRICTED_RES, - context); - var prefixNonQuarkusTest = getConfigParam( - CFGKEY_ORDER_PREFIX_NON_QUARKUS_TEST, - DEFAULT_ORDER_PREFIX_NON_QUARKUS_TEST, - context); // first pass: run secondary orderer first (!), which is easier than running it per "grouping" - buildSecondaryOrderer(context).orderClasses(context); + secondaryOrderer + .map(fqcn -> { + try { + return (ClassOrderer) Class.forName(fqcn).getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException("Failed to instantiate " + fqcn, e); + } + }) + .orElseGet(ClassName::new).orderClasses(context); + var classDecriptors = context.getClassDescriptors(); var firstPassIndexMap = IntStream.range(0, classDecriptors.size()).boxed() .collect(Collectors.toMap(classDecriptors::get, i -> String.format("%06d", i))); @@ -123,22 +151,6 @@ public void orderClasses(ClassOrdererContext context) { })); } - private String getConfigParam(String key, String fallbackValue, ClassOrdererContext context) { - return context.getConfigurationParameter(key).orElse(fallbackValue); - } - - private ClassOrderer buildSecondaryOrderer(ClassOrdererContext context) { - return Optional.ofNullable(getConfigParam(CFGKEY_SECONDARY_ORDERER, null, context)) - .map(fqcn -> { - try { - return (ClassOrderer) Class.forName(fqcn).getDeclaredConstructor().newInstance(); - } catch (ReflectiveOperationException e) { - throw new IllegalArgumentException("Failed to instantiate " + fqcn, e); - } - }) - .orElseGet(ClassOrderer.ClassName::new); - } - private boolean hasRestrictedResource(ClassDescriptor classDescriptor) { return classDescriptor.findRepeatableAnnotations(WithTestResource.class).stream() .anyMatch( diff --git a/test-framework/junit5/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/test-framework/junit5/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension deleted file mode 100644 index 3d466fc6d2db5..0000000000000 --- a/test-framework/junit5/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension +++ /dev/null @@ -1 +0,0 @@ -io.quarkus.test.junit.BasicLoggingEnabler diff --git a/test-framework/junit5/src/test/java/io/quarkus/test/junit/util/QuarkusTestProfileAwareClassOrdererTest.java b/test-framework/junit5/src/test/java/io/quarkus/test/junit/util/QuarkusTestProfileAwareClassOrdererTest.java index acb5f2cf5b834..d91035f34f311 100644 --- a/test-framework/junit5/src/test/java/io/quarkus/test/junit/util/QuarkusTestProfileAwareClassOrdererTest.java +++ b/test-framework/junit5/src/test/java/io/quarkus/test/junit/util/QuarkusTestProfileAwareClassOrdererTest.java @@ -1,7 +1,5 @@ package io.quarkus.test.junit.util; -import static io.quarkus.test.junit.util.QuarkusTestProfileAwareClassOrderer.CFGKEY_ORDER_PREFIX_NON_QUARKUS_TEST; -import static io.quarkus.test.junit.util.QuarkusTestProfileAwareClassOrderer.CFGKEY_SECONDARY_ORDERER; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doReturn; @@ -40,14 +38,12 @@ class QuarkusTestProfileAwareClassOrdererTest { @Mock ClassOrdererContext contextMock; - QuarkusTestProfileAwareClassOrderer underTest = new QuarkusTestProfileAwareClassOrderer(); - @Test void singleClass() { doReturn(Arrays.asList(descriptorMock(Test01.class))) .when(contextMock).getClassDescriptors(); - underTest.orderClasses(contextMock); + new QuarkusTestProfileAwareClassOrderer().orderClasses(contextMock); verify(contextMock, never()).getConfigurationParameter(anyString()); } @@ -89,7 +85,7 @@ void allVariants() { quarkusTestWithUnrestrictedQuarkusTestResourceDesc); doReturn(input).when(contextMock).getClassDescriptors(); - underTest.orderClasses(contextMock); + new QuarkusTestProfileAwareClassOrderer().orderClasses(contextMock); assertThat(input).containsExactly( quarkusTest1Desc, @@ -114,11 +110,7 @@ void configuredPrefix() { List input = Arrays.asList(quarkusTestDesc, nonQuarkusTestDesc); doReturn(input).when(contextMock).getClassDescriptors(); - when(contextMock.getConfigurationParameter(anyString())).thenReturn(Optional.empty()); // for strict stubbing - // prioritize unit tests - when(contextMock.getConfigurationParameter(CFGKEY_ORDER_PREFIX_NON_QUARKUS_TEST)).thenReturn(Optional.of("01_")); - - underTest.orderClasses(contextMock); + new QuarkusTestProfileAwareClassOrderer("20_", "40_", "45_", "01_", Optional.empty()).orderClasses(contextMock); assertThat(input).containsExactly(nonQuarkusTestDesc, quarkusTestDesc); } @@ -137,12 +129,8 @@ void secondaryOrderer() { quarkusTest1Desc); doReturn(input).when(contextMock).getClassDescriptors(); - when(contextMock.getConfigurationParameter(anyString())).thenReturn(Optional.empty()); // for strict stubbing - // change secondary orderer from ClassName to OrderAnnotation - when(contextMock.getConfigurationParameter(CFGKEY_SECONDARY_ORDERER)) - .thenReturn(Optional.of(ClassOrderer.OrderAnnotation.class.getName())); - - underTest.orderClasses(contextMock); + new QuarkusTestProfileAwareClassOrderer("20_", "40_", "45_", "60_", + Optional.of(ClassOrderer.OrderAnnotation.class.getName())).orderClasses(contextMock); assertThat(input).containsExactly( quarkusTest1Desc, @@ -157,14 +145,13 @@ void customOrderKey() { List input = Arrays.asList(quarkusTest1Desc, quarkusTest2Desc); doReturn(input).when(contextMock).getClassDescriptors(); - underTest = new QuarkusTestProfileAwareClassOrderer() { + new QuarkusTestProfileAwareClassOrderer() { @Override protected Optional getCustomOrderKey(ClassDescriptor classDescriptor, ClassOrdererContext context, String secondaryOrderSuffix) { return classDescriptor == quarkusTest2Desc ? Optional.of("00_first") : Optional.empty(); } - }; - underTest.orderClasses(contextMock); + }.orderClasses(contextMock); assertThat(input).containsExactly(quarkusTest2Desc, quarkusTest1Desc); } diff --git a/test-framework/keycloak-server/src/main/java/io/quarkus/test/keycloak/client/KeycloakTestClient.java b/test-framework/keycloak-server/src/main/java/io/quarkus/test/keycloak/client/KeycloakTestClient.java index 55d2b70561e22..f510c3dac6ec3 100644 --- a/test-framework/keycloak-server/src/main/java/io/quarkus/test/keycloak/client/KeycloakTestClient.java +++ b/test-framework/keycloak-server/src/main/java/io/quarkus/test/keycloak/client/KeycloakTestClient.java @@ -455,6 +455,9 @@ public record Tls(String keystore, String keystorePassword, public Tls() { this("client-keystore.p12", "password", "client-truststore.p12", "password"); } - }; + public Tls(String keystore, String truststore) { + this(keystore, "password", truststore, "password"); + } + }; } diff --git a/test-framework/pom.xml b/test-framework/pom.xml index 888a8dd222928..fef1a28244cb7 100644 --- a/test-framework/pom.xml +++ b/test-framework/pom.xml @@ -21,8 +21,8 @@ derby kubernetes-client openshift-client + junit5-config junit5-internal - junit5-properties junit5 junit5-component junit5-mockito diff --git a/test-framework/security-webauthn/pom.xml b/test-framework/security-webauthn/pom.xml index 38923a799df21..3699d95ab3cf9 100644 --- a/test-framework/security-webauthn/pom.xml +++ b/test-framework/security-webauthn/pom.xml @@ -1,7 +1,7 @@ - + io.quarkus quarkus-test-framework diff --git a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.java b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.java index f99e0b00a4057..7eb20347e2d5c 100644 --- a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.java +++ b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.java @@ -14,19 +14,18 @@ import io.vertx.core.json.JsonObject; public class WebAuthnEndpointHelper { - public static String invokeRegistration(String userName, Filter cookieFilter) { - JsonObject registerJson = new JsonObject() - .put("name", userName); + public static String obtainRegistrationChallenge(String username, Filter cookieFilter) { ExtractableResponse response = RestAssured - .given().body(registerJson.encode()) - .contentType(ContentType.JSON) + .given() + .contentType(ContentType.URLENC) .filter(cookieFilter) .log().ifValidationFails() - .post("/q/webauthn/register") - .then().statusCode(200) + .queryParam("username", username) + .get("/q/webauthn/register-options-challenge") + .then() .log().ifValidationFails() + .statusCode(200) .cookie(getChallengeCookie(), Matchers.notNullValue()) - .cookie(getChallengeUsernameCookie(), Matchers.notNullValue()) .extract(); // assert stuff JsonObject responseJson = new JsonObject(response.asString()); @@ -35,33 +34,47 @@ public static String invokeRegistration(String userName, Filter cookieFilter) { return challenge; } - public static void invokeCallback(JsonObject registration, Filter cookieFilter) { + public static void invokeLogin(JsonObject login, Filter cookieFilter) { + RestAssured + .given().body(login.encode()) + .filter(cookieFilter) + .contentType(ContentType.JSON) + .log().ifValidationFails() + .post("/q/webauthn/login") + .then() + .log().ifValidationFails() + .statusCode(204) + .cookie(getChallengeCookie(), Matchers.is("")) + .cookie(getMainCookie(), Matchers.notNullValue()); + } + + public static void invokeRegistration(String username, JsonObject registration, Filter cookieFilter) { RestAssured .given().body(registration.encode()) .filter(cookieFilter) .contentType(ContentType.JSON) .log().ifValidationFails() - .post("/q/webauthn/callback") - .then().statusCode(204) + .queryParam("username", username) + .post("/q/webauthn/register") + .then() .log().ifValidationFails() + .statusCode(204) .cookie(getChallengeCookie(), Matchers.is("")) - .cookie(getChallengeUsernameCookie(), Matchers.is("")) .cookie(getMainCookie(), Matchers.notNullValue()); } - public static String invokeLogin(String userName, Filter cookieFilter) { - JsonObject loginJson = new JsonObject() - .put("name", userName); + public static String obtainLoginChallenge(String username, Filter cookieFilter) { ExtractableResponse response = RestAssured - .given().body(loginJson.encode()) - .contentType(ContentType.JSON) + .given() + .contentType(ContentType.URLENC) .filter(cookieFilter) .log().ifValidationFails() - .post("/q/webauthn/login") - .then().statusCode(200) + .queryParam("username", username) + .get("/q/webauthn/login-options-challenge") + .then() .log().ifValidationFails() + .statusCode(200) .cookie(getChallengeCookie(), Matchers.notNullValue()) - .cookie(getChallengeUsernameCookie(), Matchers.notNullValue()) .extract(); // assert stuff JsonObject responseJson = new JsonObject(response.asString()); @@ -113,10 +126,4 @@ public static String getChallengeCookie() { return config.getOptionalValue("quarkus.webauthn.challenge-cookie-name", String.class) .orElse("_quarkus_webauthn_challenge"); } - - public static String getChallengeUsernameCookie() { - Config config = ConfigProvider.getConfig(); - return config.getOptionalValue("quarkus.webauthn.challenge-username-cookie-name", String.class) - .orElse("_quarkus_webauthn_username"); - } } diff --git a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHardware.java b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHardware.java index 3dabdb619f5f9..c8f7197d9eb3a 100644 --- a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHardware.java +++ b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHardware.java @@ -2,6 +2,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; @@ -19,18 +20,17 @@ import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import com.webauthn4j.data.attestation.authenticator.AuthenticatorData; import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; import io.vertx.ext.auth.impl.Codec; -import io.vertx.ext.auth.webauthn.impl.AuthData; /** * Provides an emulation of a WebAuthn hardware token, suitable for generating registration * and login JSON objects that you can send to the Quarkus WebAuthn Security extension. * - * The public/private key and id/credID are randomly generated and different for every instance, - * and the origin is always for http://localhost + * The public/private key and id/credID are randomly generated and different for every instance. */ public class WebAuthnHardware { @@ -38,8 +38,9 @@ public class WebAuthnHardware { private String id; private byte[] credID; private int counter = 1; + private URL origin; - public WebAuthnHardware() { + public WebAuthnHardware(URL origin) { KeyPairGenerator generator; try { generator = KeyPairGenerator.getInstance("EC"); @@ -53,6 +54,7 @@ public WebAuthnHardware() { credID = new byte[32]; random.nextBytes(credID); id = Base64.getUrlEncoder().withoutPadding().encodeToString(credID); + this.origin = origin; } /** @@ -65,11 +67,11 @@ public JsonObject makeRegistrationJson(String challenge) { JsonObject clientData = new JsonObject() .put("type", "webauthn.create") .put("challenge", challenge) - .put("origin", "http://localhost") + .put("origin", origin.toString()) .put("crossOrigin", false); String clientDataEncoded = Base64.getUrlEncoder().encodeToString(clientData.encode().getBytes(StandardCharsets.UTF_8)); - byte[] authBytes = makeAuthBytes(); + byte[] authBytes = makeAuthBytes(true); /* * {"fmt": "none", "attStmt": {}, "authData": h'DATAAAAA'} */ @@ -108,12 +110,12 @@ public JsonObject makeLoginJson(String challenge) { JsonObject clientData = new JsonObject() .put("type", "webauthn.get") .put("challenge", challenge) - .put("origin", "http://localhost") + .put("origin", origin.toString()) .put("crossOrigin", false); byte[] clientDataBytes = clientData.encode().getBytes(StandardCharsets.UTF_8); String clientDataEncoded = Base64.getUrlEncoder().encodeToString(clientDataBytes); - byte[] authBytes = makeAuthBytes(); + byte[] authBytes = makeAuthBytes(false); String authenticatorData = Base64.getUrlEncoder().encodeToString(authBytes); // sign the authbytes + hash(client data json) @@ -148,7 +150,7 @@ public JsonObject makeLoginJson(String challenge) { .put("type", "public-key"); } - private byte[] makeAuthBytes() { + private byte[] makeAuthBytes(boolean attest) { Buffer buffer = Buffer.buffer(); String rpDomain = "localhost"; @@ -161,45 +163,47 @@ private byte[] makeAuthBytes() { byte[] rpIdHash = md.digest(rpDomain.getBytes(StandardCharsets.UTF_8)); buffer.appendBytes(rpIdHash); - byte flags = AuthData.ATTESTATION_DATA | AuthData.USER_PRESENT; + byte flags = AuthenticatorData.BIT_AT | AuthenticatorData.BIT_UP | AuthenticatorData.BIT_UV; buffer.appendByte(flags); long signCounter = counter++; buffer.appendUnsignedInt(signCounter); - // Attested Data is present - String aaguidString = "00000000-0000-0000-0000-000000000000"; - String aaguidStringShort = aaguidString.replace("-", ""); - byte[] aaguid = Codec.base16Decode(aaguidStringShort); - buffer.appendBytes(aaguid); - - buffer.appendUnsignedShort(credID.length); - buffer.appendBytes(credID); - - ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic(); - Encoder urlEncoder = Base64.getUrlEncoder(); - String x = urlEncoder.encodeToString(publicKey.getW().getAffineX().toByteArray()); - String y = urlEncoder.encodeToString(publicKey.getW().getAffineY().toByteArray()); - - CBORFactory cborFactory = new CBORFactory(); - ByteArrayOutputStream byteWriter = new ByteArrayOutputStream(); - try { - JsonGenerator generator = cborFactory.createGenerator(byteWriter); - generator.writeStartObject(); - // see CWK and https://tools.ietf.org/html/rfc8152#section-7.1 - generator.writeNumberField("1", 2); // kty: "EC" - generator.writeNumberField("3", -7); // alg: "ES256" - generator.writeNumberField("-1", 1); // crv: "P-256" - // https://tools.ietf.org/html/rfc8152#section-13.1.1 - generator.writeStringField("-2", x); // x, base64url - generator.writeStringField("-3", y); // y, base64url - generator.writeEndObject(); - generator.close(); - } catch (IOException t) { - throw new RuntimeException(t); + if (attest) { + // Attested Data is present + String aaguidString = "00000000-0000-0000-0000-000000000000"; + String aaguidStringShort = aaguidString.replace("-", ""); + byte[] aaguid = Codec.base16Decode(aaguidStringShort); + buffer.appendBytes(aaguid); + + buffer.appendUnsignedShort(credID.length); + buffer.appendBytes(credID); + + ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic(); + // NOTE: this used to be Base64 URL, but webauthn4j refuses it and wants Base64. I can't find in the spec where it's specified. + Encoder urlEncoder = Base64.getEncoder(); + String x = urlEncoder.encodeToString(publicKey.getW().getAffineX().toByteArray()); + String y = urlEncoder.encodeToString(publicKey.getW().getAffineY().toByteArray()); + + CBORFactory cborFactory = new CBORFactory(); + ByteArrayOutputStream byteWriter = new ByteArrayOutputStream(); + try { + JsonGenerator generator = cborFactory.createGenerator(byteWriter); + generator.writeStartObject(); + // see CWK and https://tools.ietf.org/html/rfc8152#section-7.1 + generator.writeNumberField("1", 2); // kty: "EC" + generator.writeNumberField("3", -7); // alg: "ES256" + generator.writeNumberField("-1", 1); // crv: "P-256" + // https://tools.ietf.org/html/rfc8152#section-13.1.1 + generator.writeStringField("-2", x); // x, base64url + generator.writeStringField("-3", y); // y, base64url + generator.writeEndObject(); + generator.close(); + } catch (IOException t) { + throw new RuntimeException(t); + } + buffer.appendBytes(byteWriter.toByteArray()); } - buffer.appendBytes(byteWriter.toByteArray()); - return buffer.getBytes(); } diff --git a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHelper.java b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHelper.java new file mode 100644 index 0000000000000..19011267cb182 --- /dev/null +++ b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHelper.java @@ -0,0 +1,265 @@ +package io.quarkus.test.security.webauthn; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import com.fasterxml.jackson.dataformat.cbor.CBORParser; +import com.webauthn4j.util.Base64UrlUtil; + +import io.quarkus.security.webauthn.WebAuthnLoginResponse; +import io.quarkus.security.webauthn.WebAuthnRegisterResponse; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonObject; + +public class WebAuthnHelper { + public static class PrettyPrinter { + private int indent; + + private void indent() { + for (int i = 0; i < indent; i++) { + System.err.print(" "); + } + } + + public void handleToken(CBORParser parser, JsonToken t) throws IOException { + switch (t) { + case END_ARRAY: + endArray(); + break; + case END_OBJECT: + endObject(); + break; + case FIELD_NAME: + fieldName(parser.currentName()); + break; + case NOT_AVAILABLE: + break; + case START_ARRAY: + startArray(); + break; + case START_OBJECT: + startObject(); + break; + case VALUE_EMBEDDED_OBJECT: + Object embeddedObject = parser.getEmbeddedObject(); + if (parser.currentName().equals("authData")) { + dumpAuthData((byte[]) embeddedObject); + } else { + System.err.println(embeddedObject); + } + break; + case VALUE_FALSE: + falseConstant(); + break; + case VALUE_NULL: + nullConstant(); + break; + case VALUE_NUMBER_FLOAT: + floatValue(parser.getFloatValue()); + break; + case VALUE_NUMBER_INT: + intValue(parser.getNumberValue()); + break; + case VALUE_STRING: + stringValue(parser.getValueAsString()); + break; + case VALUE_TRUE: + trueConstant(); + break; + default: + break; + + } + + } + + private void floatValue(float floatValue) { + System.err.println(floatValue); + } + + private void intValue(Number numberValue) { + System.err.println(numberValue); + } + + private void stringValue(String value) { + System.err.println("\"" + value + "\""); + } + + private void nullConstant() { + System.err.println("null"); + } + + private void trueConstant() { + System.err.println("true"); + } + + private void falseConstant() { + System.err.println("false"); + } + + private void startObject() { + indent(); + System.err.println("{"); + indent++; + } + + private void startArray() { + indent(); + System.err.println("["); + indent++; + } + + public void fieldName(String name) { + indent(); + System.err.print("\""); + System.err.print(name); + System.err.print("\": "); + } + + public void endObject() { + indent(); + System.err.println("}"); + indent--; + } + + public void endArray() { + indent(); + System.err.println("]"); + indent--; + } + + private void dumpAuthData(byte[] embeddedObject) throws IOException { + Buffer buf = Buffer.buffer(embeddedObject); + startObject(); + int current = 0; + byte[] rpIdHash = buf.getBytes(0, 32); + current += 32; + fieldName("rpIdHash"); + stringValue(""); // TODO + byte flags = buf.getByte(current); + current += 1; + fieldName("flags"); + intValue(flags); // TODO in binary + long counter = buf.getUnsignedInt(current); + current += 4; + fieldName("counter"); + intValue(counter); + if (embeddedObject.length > current) { + fieldName("attestedCredentialData"); + startObject(); + byte[] aaguid = buf.getBytes(current, current + 16); + current += 16; + fieldName("aaguid"); + stringValue(Base64UrlUtil.encodeToString(aaguid)); + + int credentialIdLength = buf.getUnsignedShort(current); + current += 2; + fieldName("credentialIdLength"); + intValue(credentialIdLength); + + byte[] credentialId = buf.getBytes(current, current + credentialIdLength); + current += credentialIdLength; + fieldName("credentialId"); + stringValue(Base64UrlUtil.encodeToString(credentialId)); + + fieldName("credentialPublicKey"); + current += readCBOR(embeddedObject, current); + + endObject(); + } + // TODO: there's more + endObject(); + } + + private int readCBOR(byte[] bytes, int offset) throws IOException { + CBORFactory factory = CBORFactory.builder().build(); + long lastReadByte = offset; + try (CBORParser parser = factory.createParser(bytes, offset, bytes.length - offset)) { + JsonToken t; + while ((t = parser.nextToken()) != null) { + // System.err.println("Token: "+t); + handleToken(parser, t); + } + lastReadByte = parser.currentLocation().getByteOffset(); + } + return (int) (lastReadByte - offset); + } + } + + public static void dumpWebAuthnRequest(JsonObject json) throws IOException { + System.err.println(json.encodePrettily()); + JsonObject response = json.getJsonObject("response"); + if (response != null) { + String attestationObject = response.getString("attestationObject"); + if (attestationObject != null) { + System.err.println("Attestation object:"); + dumpAttestationObject(attestationObject); + } + String authenticatorData = response.getString("authenticatorData"); + if (authenticatorData != null) { + System.err.println("Authenticator Data:"); + dumpAuthenticatorData(authenticatorData); + } + String clientDataJSON = response.getString("clientDataJSON"); + if (clientDataJSON != null) { + System.err.println("Client Data JSON:"); + String encoded = new String(Base64UrlUtil.decode(clientDataJSON), StandardCharsets.UTF_8); + System.err.println(new JsonObject(encoded).encodePrettily()); + } + } + } + + private static void dumpAttestationObject(String attestationObject) throws IOException { + CBORFactory factory = CBORFactory.builder().build(); + PrettyPrinter printer = new PrettyPrinter(); + try (CBORParser parser = factory.createParser(Base64UrlUtil.decode(attestationObject))) { + JsonToken t; + while ((t = parser.nextToken()) != null) { + // System.err.println("Token: "+t); + printer.handleToken(parser, t); + } + } + } + + private static void dumpAuthenticatorData(String authenticatorData) throws IOException { + PrettyPrinter printer = new PrettyPrinter(); + byte[] bytes = Base64UrlUtil.decode(authenticatorData); + printer.dumpAuthData(bytes); + } + + public static void dumpWebAuthnRequest(WebAuthnRegisterResponse response) { + try { + dumpWebAuthnRequest(response.toJsonObject()); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static void dumpWebAuthnRequest(WebAuthnLoginResponse response) { + try { + dumpWebAuthnRequest(response.toJsonObject()); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static void main(String[] args) { + // WebAuthnRegisterResponse response = new WebAuthnRegisterResponse(); + // response.webAuthnId = "N3P8WalYEtlUPMcD8q7C8hfY9tZ-DZBl7oPZNGMBxjk"; + // response.webAuthnRawId = "N3P8WalYEtlUPMcD8q7C8hfY9tZ-DZBl7oPZNGMBxjk"; + // response.webAuthnResponseAttestationObject = "v2NmbXRkbm9uZWdhdHRTdG10v_9oYXV0aERhdGFYxUmWDeWIDoxodDQXD2R2YFuP5K65ooYyx5lc87qDHZdjQQAAAAEAAAAAAAAAAAAAAAAAAAAAACA3c_xZqVgS2VQ8xwPyrsLyF9j21n4NkGXug9k0YwHGOb9hMQJhMyZiLTEBYi0yeCxGR0hxMHlCTWJ5X1RuOGpmWlU4XzZSTDlFNFg4ZnhJSkVOY05NN3UtSEFRPWItM3gsQUtNVEtFRG5DSzhnVVNxamRtdU45bnVzbTRRRXJNY0pBNjV6OWhJOW5TWlP__w=="; + // response.webAuthnResponseClientDataJSON = "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibk9uVHRac1diSE5rRWNhOEZYY29NVUdIanJOY1c4S1BybWg0REFPQXFxaUVvRDNYdHhVT09TcXFiVXFndHlEbkEzU1VCM25YS21PRUp2WGNFZTBfVnciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0IiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ=="; + // response.webAuthnType = "public-key"; + // dumpWebAuthnRequest(response); + WebAuthnLoginResponse response = new WebAuthnLoginResponse(); + response.webAuthnId = "cmokxFnWpNiqBDgI8qL41usvkUCeZC_J8EVS_jD0Brw"; + response.webAuthnRawId = "cmokxFnWpNiqBDgI8qL41usvkUCeZC_J8EVS_jD0Brw"; + response.webAuthnResponseAuthenticatorData = "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAAAgAAAAAAAAAAAAAAAAAAAAAAIHJqJMRZ1qTYqgQ4CPKi-NbrL5FAnmQvyfBFUv4w9Aa8v2ExAmEzJmItMQFiLTJ4LFhCbHRrY25LZ2xjTU94bmZYSnAydE1xc2RESFBhNVB5YnIvaFJUY2tSU3c9Yi0zeCxjZWQvdHRvZGdaQjhmUGdHZ0NIM3lIUUU5NjUzVk5GdTNET2JqNFNtZ2dRPf8="; + response.webAuthnResponseSignature = "MEUCIQDlT0NRyeElINrF59m54fsAhjVh09ykApfKzUsFw1qCVQIgHHkrPCQedlVo_fWcb7p8ch7tAT8mt3h3-GihBUP8s4o="; + response.webAuthnResponseClientDataJSON = "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiQTdYTDFvOTlINEdRZXBESDI5MnAzSUdwU0NNRHU3cUVlRVEwY3dWMU5BYm82ZzFjbC1yc2pGRHZuaVdwbE5hdk1rX1Z4YkJVcG8wdE00c2V2bXpaQUEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwODEvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ=="; + response.webAuthnType = "public-key"; + dumpWebAuthnRequest(response); + } +} diff --git a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnTestUserProvider.java b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnTestUserProvider.java index b18ad798661b8..29976565a4a40 100644 --- a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnTestUserProvider.java +++ b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnTestUserProvider.java @@ -1,30 +1,30 @@ package io.quarkus.test.security.webauthn; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Set; +import com.webauthn4j.util.Base64UrlUtil; + +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnUserProvider; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; /** - * UserProvider suitable for tests, which stores and updates credentials in a list, + * UserProvider suitable for tests, which fetches and updates credentials from a list, * so you can use it in your tests. - * - * @see WebAuthnStoringTestUserProvider - * @see WebAuthnManualTestUserProvider */ public class WebAuthnTestUserProvider implements WebAuthnUserProvider { - private List auths = new ArrayList<>(); + private List auths = new ArrayList<>(); @Override - public Uni> findWebAuthnCredentialsByUserName(String userId) { - List ret = new ArrayList<>(); - for (Authenticator authenticator : auths) { - if (authenticator.getUserName().equals(userId)) { + public Uni> findByUsername(String userId) { + List ret = new ArrayList<>(); + for (WebAuthnCredentialRecord authenticator : auths) { + if (authenticator.getUsername().equals(userId)) { ret.add(authenticator); } } @@ -32,63 +32,50 @@ public Uni> findWebAuthnCredentialsByUserName(String userId) } @Override - public Uni> findWebAuthnCredentialsByCredID(String credId) { - List ret = new ArrayList<>(); - for (Authenticator authenticator : auths) { - if (authenticator.getCredID().equals(credId)) { - ret.add(authenticator); + public Uni findByCredentialId(String credId) { + byte[] bytes = Base64UrlUtil.decode(credId); + for (WebAuthnCredentialRecord authenticator : auths) { + if (Arrays.equals(authenticator.getAttestedCredentialData().getCredentialId(), bytes)) { + return Uni.createFrom().item(authenticator); } } - return Uni.createFrom().item(ret); + return Uni.createFrom().failure(new RuntimeException("Credentials not found for credential ID " + credId)); } @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { - Authenticator existing = find(authenticator.getUserName(), authenticator.getCredID()); - if (existing != null) { - // update - existing.setCounter(authenticator.getCounter()); - } else { - // add - store(authenticator); - } - return Uni.createFrom().nullItem(); + public Uni update(String credentialId, long counter) { + reallyUpdate(credentialId, counter); + return Uni.createFrom().voidItem(); } - private Authenticator find(String userName, String credID) { - for (Authenticator auth : auths) { - if (userName.equals(auth.getUserName()) - && credID.equals(auth.getCredID())) { - // update - return auth; - } - } - return null; + @Override + public Uni store(WebAuthnCredentialRecord credentialRecord) { + reallyStore(credentialRecord); + return Uni.createFrom().voidItem(); } @Override - public Set getRoles(String userId) { + public Set getRoles(String username) { return Collections.singleton("admin"); } - /** - * Stores a new credential - * - * @param authenticator the new credential to store - */ - public void store(Authenticator authenticator) { - auths.add(authenticator); + // For tests + + public void clear() { + auths.clear(); + } + + public void reallyUpdate(String credentialId, long counter) { + byte[] bytes = Base64UrlUtil.decode(credentialId); + for (WebAuthnCredentialRecord authenticator : auths) { + if (Arrays.equals(authenticator.getAttestedCredentialData().getCredentialId(), bytes)) { + authenticator.setCounter(counter); + break; + } + } } - /** - * Updates an existing credential - * - * @param userName the user name - * @param credID the credential ID - * @param counter the new counter value - */ - public void update(String userName, String credID, long counter) { - Authenticator authenticator = find(userName, credID); - authenticator.setCounter(counter); + public void reallyStore(WebAuthnCredentialRecord credentialRecord) { + auths.add(credentialRecord); } } \ No newline at end of file

    Quarkus${build.quarkusVersion}
    App Name${unsafeHTML(build.name)}
    Group${build.group}
    Artifact${build.artifact}
    Version${build.version}