diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index fb133d37008db..8fbd5380b8677 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -12,7 +12,7 @@ com.gradle quarkus-build-caching-extension - 1.1 + 1.2 io.quarkus.develocity diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/QuarkusDevModeLauncher.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/QuarkusDevModeLauncher.java index 580506a736255..d5e2b05b68da4 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/QuarkusDevModeLauncher.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/QuarkusDevModeLauncher.java @@ -85,12 +85,6 @@ public B debug(String debug) { return (B) this; } - @SuppressWarnings("unchecked") - public B debugPortOk(Boolean debugPortOk) { - QuarkusDevModeLauncher.this.debugPortOk = debugPortOk; - return (B) this; - } - @SuppressWarnings("unchecked") public B suspend(String suspend) { QuarkusDevModeLauncher.this.suspend = suspend; @@ -303,10 +297,10 @@ public R build() throws Exception { private List args = new ArrayList<>(0); private String debug; - private Boolean debugPortOk; private String suspend; private String debugHost = "localhost"; private String debugPort = "5005"; + private String actualDebugPort; private File projectDir; private File buildDir; private File outputDir; @@ -390,12 +384,13 @@ protected void prepare() throws Exception { if (debug != null && debug.equalsIgnoreCase("client")) { args.add("-agentlib:jdwp=transport=dt_socket,address=" + debugHost + ":" + port + ",server=n,suspend=" + suspend); + actualDebugPort = String.valueOf(port); } else if (debug == null || !debug.equalsIgnoreCase("false")) { // if the debug port is used, we want to make an effort to pick another one // if we can't find an open port, we don't fail the process launch, we just don't enable debugging // Furthermore, we don't check this on restarts, as the previous process is still running boolean warnAboutChange = false; - if (debugPortOk == null) { + if (actualDebugPort == null) { int tries = 0; while (true) { boolean isPortUsed; @@ -408,20 +403,19 @@ protected void prepare() throws Exception { isPortUsed = false; } if (!isPortUsed) { - debugPortOk = true; + actualDebugPort = String.valueOf(port); break; } if (++tries >= 5) { - debugPortOk = false; break; } else { port = getRandomPort(); } } } - if (debugPortOk) { + if (actualDebugPort != null) { if (warnAboutChange) { - warn("Changed debug port to " + port + " because of a port conflict"); + warn("Changed debug port to " + actualDebugPort + " because of a port conflict"); } args.add("-agentlib:jdwp=transport=dt_socket,address=" + debugHost + ":" + port + ",server=y,suspend=" + suspend); @@ -547,8 +541,8 @@ public List args() { return args; } - public Boolean getDebugPortOk() { - return debugPortOk; + public String getActualDebugPort() { + return actualDebugPort; } protected abstract boolean isDebugEnabled(); diff --git a/core/runtime/src/main/java/io/quarkus/runtime/ApplicationLifecycleManager.java b/core/runtime/src/main/java/io/quarkus/runtime/ApplicationLifecycleManager.java index a410463dba571..aacaab97261d0 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/ApplicationLifecycleManager.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/ApplicationLifecycleManager.java @@ -183,7 +183,7 @@ public static void run(Application application, Class'."); 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 f3cd59c9d8f65..ffc0564ff65e0 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 @@ -234,7 +234,8 @@ private void setUpDeploymentConfiguration() { private void setUpCompileOnlyConfiguration() { if (!project.getConfigurations().getNames().contains(compileOnlyConfigurationName)) { project.getConfigurations().register(compileOnlyConfigurationName, config -> { - config.extendsFrom(project.getConfigurations().getByName(JavaPlugin.COMPILE_ONLY_CONFIGURATION_NAME)); + config.extendsFrom(project.getConfigurations().getByName(platformConfigurationName), + project.getConfigurations().getByName(JavaPlugin.COMPILE_ONLY_CONFIGURATION_NAME)); config.shouldResolveConsistentlyWith(getDeploymentConfiguration()); config.setCanBeConsumed(false); }); diff --git a/devtools/maven/src/main/java/io/quarkus/maven/DevMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/DevMojo.java index 409700ecb15ef..e8480da05aca4 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/DevMojo.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/DevMojo.java @@ -478,15 +478,19 @@ public void close() throws IOException { } if (!changed.isEmpty()) { getLog().info("Changes detected to " + changed + ", restarting dev mode"); + + // stop the runner before we build the new one as the debug port being free + // is tested when building the runner + runner.stop(); + final DevModeRunner newRunner; try { bootstrapId = handleAutoCompile(); - newRunner = new DevModeRunner(runner.launcher.getDebugPortOk(), bootstrapId); + newRunner = new DevModeRunner(runner.launcher.getActualDebugPort(), bootstrapId); } catch (Exception e) { getLog().info("Could not load changed pom.xml file, changes not applied", e); continue; } - runner.stop(); newRunner.run(); runner = newRunner; } @@ -1171,8 +1175,8 @@ private DevModeRunner(String bootstrapId) throws Exception { launcher = newLauncher(null, bootstrapId); } - private DevModeRunner(Boolean debugPortOk, String bootstrapId) throws Exception { - launcher = newLauncher(debugPortOk, bootstrapId); + private DevModeRunner(String actualDebugPort, String bootstrapId) throws Exception { + launcher = newLauncher(actualDebugPort, bootstrapId); } Collection pomFiles() { @@ -1226,7 +1230,7 @@ void stop() throws InterruptedException { } } - private QuarkusDevModeLauncher newLauncher(Boolean debugPortOk, String bootstrapId) throws Exception { + private QuarkusDevModeLauncher newLauncher(String actualDebugPort, String bootstrapId) throws Exception { String java = null; // See if a toolchain is configured if (toolchainManager != null) { @@ -1244,8 +1248,7 @@ private QuarkusDevModeLauncher newLauncher(Boolean debugPortOk, String bootstrap .suspend(suspend) .debug(debug) .debugHost(debugHost) - .debugPort(debugPort) - .debugPortOk(debugPortOk) + .debugPort(actualDebugPort) .deleteDevJar(deleteDevJar); setJvmArgs(builder); diff --git a/docs/src/main/asciidoc/building-my-first-extension.adoc b/docs/src/main/asciidoc/building-my-first-extension.adoc index 39de8983d4975..fcc64b2a35b78 100644 --- a/docs/src/main/asciidoc/building-my-first-extension.adoc +++ b/docs/src/main/asciidoc/building-my-first-extension.adoc @@ -29,6 +29,12 @@ Keep in mind it's not representative of the power of moving things to build time :prerequisites-no-graalvm: include::{includes}/prerequisites.adoc[] +[CAUTION] +==== +Writing extension with any other than Java and Maven has **not** been tested by the Quarkus team so your mileage may vary +if you stray off this path +==== + == Basic Concepts First things first, we will need to start with some basic concepts. diff --git a/docs/src/main/asciidoc/datasource.adoc b/docs/src/main/asciidoc/datasource.adoc index 5924e3b1b9b03..3b8fcd73aa948 100644 --- a/docs/src/main/asciidoc/datasource.adoc +++ b/docs/src/main/asciidoc/datasource.adoc @@ -531,7 +531,7 @@ would result in an exception similar to this: Caused by: java.sql.SQLException: Exception in association of connection to existing transaction at io.agroal.narayana.NarayanaTransactionIntegration.associate(NarayanaTransactionIntegration.java:130) ... -Caused by: java.sql.SQLException: Unable to enlist connection to existing transaction +Caused by: java.sql.SQLException: Failed to enlist. Check if a connection from another datasource is already enlisted to the same transaction at io.agroal.narayana.NarayanaTransactionIntegration.associate(NarayanaTransactionIntegration.java:121) ... ---- diff --git a/docs/src/main/asciidoc/deploying-to-kubernetes.adoc b/docs/src/main/asciidoc/deploying-to-kubernetes.adoc index 8dc1c5784ed1b..3d68a1fae1803 100644 --- a/docs/src/main/asciidoc/deploying-to-kubernetes.adoc +++ b/docs/src/main/asciidoc/deploying-to-kubernetes.adoc @@ -1462,8 +1462,13 @@ spec: protocol: "TCP" serviceAccount: "kubernetes-quickstart" ---- +<1> The provided replicas, +<2> labels and +<3> environment variables were retained. +<4> However, the image and +<5> the container port were modified. -The provided replicas <1>, labels <2> and environment variables <3> were retained. However, the image <4> and container port <5> were modified. Moreover, the default annotations have been added. +Moreover, the default annotations have been added. [NOTE] ==== diff --git a/docs/src/main/asciidoc/hibernate-orm-panache.adoc b/docs/src/main/asciidoc/hibernate-orm-panache.adoc index 9726e5eafe168..9be07480f99a2 100644 --- a/docs/src/main/asciidoc/hibernate-orm-panache.adoc +++ b/docs/src/main/asciidoc/hibernate-orm-panache.adoc @@ -792,8 +792,8 @@ public class Person extends PanacheEntity { [WARNING] ==== -Named queries can only be defined inside your Jakarta Persistence entity classes (being the Panache entity class, or the repository parameterized type), -or on one of its super classes. +Named queries can only be defined inside your Jakarta Persistence entity classes, +or on one of their super classes. ==== === Query parameters diff --git a/docs/src/main/asciidoc/hibernate-reactive-panache.adoc b/docs/src/main/asciidoc/hibernate-reactive-panache.adoc index 8b6d16a9ad2f4..3b542fc98833d 100644 --- a/docs/src/main/asciidoc/hibernate-reactive-panache.adoc +++ b/docs/src/main/asciidoc/hibernate-reactive-panache.adoc @@ -553,8 +553,8 @@ public class Person extends PanacheEntity { [WARNING] ==== -Named queries can only be defined inside your Jakarta Persistence entity classes (being the Panache entity class, or the repository parameterized type), -or on one of its super classes. +Named queries can only be defined inside your Jakarta Persistence entity classes, +or on one of their super classes. ==== === Query parameters diff --git a/docs/src/main/asciidoc/kafka.adoc b/docs/src/main/asciidoc/kafka.adoc index ff532b0c198ab..04b2d9cbd0041 100644 --- a/docs/src/main/asciidoc/kafka.adoc +++ b/docs/src/main/asciidoc/kafka.adoc @@ -361,7 +361,9 @@ If `checkpoint.unsynced-state-max-age.ms` is set to less than or equal to 0, it For more information, see <> - `latest` commits the record offset received by the Kafka consumer as soon as the associated message is acknowledged (if the offset is higher than the previously committed offset). -This strategy provides at-least-once delivery if the channel processes the message without performing any asynchronous processing. +This strategy provides at-least-once delivery if the channel processes the message without performing any asynchronous processing. Specifically, the offset of the most recent acknowledged +message will always be committed, even if older messages have not finished being processed. In case of an incident such as a crash, processing would restart after the last commit, leading +to older messages never being successfully and fully processed, which would appear as message loss. This strategy should not be used in high load environment, as offset commit is expensive. However, it reduces the risk of duplicates. - `ignore` performs no commit. This strategy is the default strategy when the consumer is explicitly configured with `enable.auto.commit` to true. diff --git a/docs/src/main/asciidoc/mutiny-primer.adoc b/docs/src/main/asciidoc/mutiny-primer.adoc index 81d5ec2905b26..8f4942ce938b9 100644 --- a/docs/src/main/asciidoc/mutiny-primer.adoc +++ b/docs/src/main/asciidoc/mutiny-primer.adoc @@ -3,7 +3,7 @@ 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 //// -= Mutiny - Async for bare mortal += Mutiny - Async for mere mortals include::_attributes.adoc[] :categories: reactive :topics: mutiny,reactive diff --git a/docs/src/main/asciidoc/stylesheet/config.css b/docs/src/main/asciidoc/stylesheet/config.css index f7b70fabae1ad..53ca077e66cf5 100644 --- a/docs/src/main/asciidoc/stylesheet/config.css +++ b/docs/src/main/asciidoc/stylesheet/config.css @@ -22,15 +22,11 @@ table.configuration-reference.tableblock > tbody > tr:nth-child(even) > th { table.configuration-reference.tableblock > tbody > tr > th { background-color: transparent; font-size: 1rem; - height: 60px; border: none; border-bottom: 1px solid #4695eb; vertical-align: bottom; } -table.configuration-reference.tableblock > tbody > tr:first-child > th { - height: 30px; -} table.configuration-reference.tableblock > tbody > tr > th:nth-child(2), table.configuration-reference.tableblock > tbody > tr > th:nth-child(3), table.configuration-reference.tableblock > tbody > tr > td:nth-child(2), diff --git a/docs/src/main/asciidoc/websockets-next-reference.adoc b/docs/src/main/asciidoc/websockets-next-reference.adoc index 62039a09f8114..01c6ad2e00d8f 100644 --- a/docs/src/main/asciidoc/websockets-next-reference.adoc +++ b/docs/src/main/asciidoc/websockets-next-reference.adoc @@ -16,6 +16,8 @@ include::_attributes.adoc[] include::{includes}/extension-status.adoc[] +The `quarkus-websockets-next` extension provides a modern declarative API to define WebSocket server and client endpoints. + == The WebSocket protocol The _WebSocket_ protocol, documented in the https://datatracker.ietf.org/doc/html/rfc6455[RFC6455], establishes a standardized method for creating a bidirectional communication channel between a client and a server through a single TCP connection. @@ -137,13 +139,13 @@ Meanwhile, the `consumeNested` method within the nested class can access both `v [source, java] ---- -@WebSocket("/ws/v{version}") +@WebSocket(path = "/ws/v{version}") public class MyPrimaryWebSocket { @OnTextMessage void consumePrimary(String s) { ... } - @WebSocket("/products/{id}") + @WebSocket(path = "/products/{id}") public static class MyNestedWebSocket { @OnTextMessage @@ -161,12 +163,12 @@ However, developers can specify alternative scopes to suit their specific requir [source,java] ---- -@WebSocket("/ws") +@WebSocket(path = "/ws") public class MyWebSocket { // Singleton scoped bean } -@WebSocket("/ws") +@WebSocket(path = "/ws") @ApplicationScoped public class MyRequestScopedWebSocket { // Application scoped. @@ -418,7 +420,7 @@ Methods annotated with `@OnOpen` can utilize server-side streaming by returning [source, java] ---- -@WebSocket("/foo") +@WebSocket(path = "/foo") @OnOpen public Multi streaming() { return Multi.createFrom().ticks().every(Duration.ofSecond(1)) @@ -457,6 +459,10 @@ The method that declares a most-specific supertype of the actual exception is se NOTE: The `@io.quarkus.websockets.next.OnError` annotation can be also used to declare a global error handler, i.e. a method that is not declared on a WebSocket endpoint. Such a method may not accept `@PathParam` paremeters. Error handlers declared on an endpoint take precedence over the global error handlers. +When an error occurs but no error handler can handle the failure, Quarkus uses the strategy specified by `quarkus.websockets-next.server.unhandled-failure-strategy` and `quarkus.websockets-next.client.unhandled-failure-strategy`, respectively. +By default, the connection is closed. +Alternatively, an error message can be logged or no operation performed. + == Access to the WebSocketConnection The `io.quarkus.websockets.next.WebSocketConnection` object represents the WebSocket connection. @@ -635,6 +641,8 @@ quarkus.http.auth.permission.secured.policy=authenticated Other options for securing HTTP upgrade requests, such as using the security annotations, will be explored in the future. +NOTE: When OpenID Connect extension is used and token expires, Quarkus automatically closes connection. + [[websocket-next-configuration-reference]] == Configuration reference diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index ace3645ff8dd3..84290b257abb7 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -642,7 +642,10 @@ public enum Strategy { * either `quarkus.oidc.credentials.secret` or `quarkus.oidc.credentials.client-secret.value` is checked. * Finally, `quarkus.oidc.credentials.jwt.secret` which can be used for `client_jwt_secret` authentication is * checked. - * The secret is auto-generated if it remains uninitialized after checking all of these properties. + * The secret is auto-generated every time an application starts if it remains uninitialized after checking all of these + * properties. + * Generated secret can not decrypt the session cookie encrypted before the restart, therefore a user re-authentication + * will be required. *

* The length of the secret used to encrypt the tokens should be at least 32 characters long. * A warning is logged if the secret length is less than 16 characters. diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java index a11fec4b2baef..442032e00a079 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java @@ -119,7 +119,12 @@ private static SecretKey createTokenEncSecretKey(OidcTenantConfig config) { } try { if (encSecret == null) { - LOG.warn("Secret key for encrypting tokens in a session cookie is missing, auto-generating it"); + LOG.warn( + "Secret key for encrypting OIDC authorization code flow tokens in a session cookie is not configured, auto-generating it." + + " Note that a new secret will be generated after a restart, thus making it impossible to decrypt the session cookie and requiring a user re-authentication." + + " Use 'quarkus.oidc.token-state-manager.encryption-secret' to configure an encryption secret." + + " Alternatively, disable session cookie encryption with 'quarkus.oidc.token-state-manager.encryption-required=false'" + + " but only if it is considered to be safe in your application's network."); return generateSecretKey(); } byte[] secretBytes = encSecret.getBytes(StandardCharsets.UTF_8); diff --git a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java index 67282afd1c3db..c3cb66fbf2b9a 100644 --- a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java +++ b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java @@ -1001,7 +1001,7 @@ public String apply(String id) { // Register all param declarations as targets of implicit value resolvers for (ParameterDeclaration paramDeclaration : templateAnalysis.parameterDeclarations) { Type type = TypeInfos.resolveTypeFromTypeInfo(paramDeclaration.getTypeInfo()); - if (type != null) { + if (type != null && !implicitClassToMembersUsed.containsKey(type.name())) { implicitClassToMembersUsed.put(type.name(), new HashSet<>()); } } diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/generatedresolvers/ImplicitValueResolversTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/generatedresolvers/ImplicitValueResolversTest.java new file mode 100644 index 0000000000000..2b740c276290d --- /dev/null +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/generatedresolvers/ImplicitValueResolversTest.java @@ -0,0 +1,61 @@ +package io.quarkus.qute.deployment.generatedresolvers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.qute.CheckedTemplate; +import io.quarkus.qute.Engine; +import io.quarkus.qute.TemplateInstance; +import io.quarkus.qute.ValueResolver; +import io.quarkus.qute.generator.ValueResolverGenerator; +import io.quarkus.test.QuarkusUnitTest; + +public class ImplicitValueResolversTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset("{name.toUpperCase}"), "templates/hello.html") + .addAsResource(new StringAsset("{name}"), "templates/bye.html") + .addAsResource(new StringAsset("{name}"), "templates/zero.html")); + + @CheckedTemplate(basePath = "") + record hello(String name) implements TemplateInstance { + }; + + @CheckedTemplate(basePath = "") + record bye(String name) implements TemplateInstance { + }; + + @CheckedTemplate(basePath = "") + record zero(String name) implements TemplateInstance { + }; + + @Inject + Engine engine; + + @Test + public void testImplicitResolvers() { + assertEquals("FOO", new hello("Foo").render()); + assertEquals("Bar", new bye("Bar").render()); + assertEquals("Baz", new zero("Baz").render()); + List resolvers = engine.getValueResolvers(); + ValueResolver stringResolver = null; + for (ValueResolver valueResolver : resolvers) { + if (valueResolver.getClass().getName().endsWith(ValueResolverGenerator.SUFFIX) + && valueResolver.getClass().getName().contains("String")) { + stringResolver = valueResolver; + } + } + assertNotNull(stringResolver); + } + +} diff --git a/extensions/resteasy-classic/resteasy-common/deployment/src/main/java/io/quarkus/resteasy/common/deployment/ResteasyCommonProcessor.java b/extensions/resteasy-classic/resteasy-common/deployment/src/main/java/io/quarkus/resteasy/common/deployment/ResteasyCommonProcessor.java index b78d5eec48b58..d2fcf7fd846d2 100644 --- a/extensions/resteasy-classic/resteasy-common/deployment/src/main/java/io/quarkus/resteasy/common/deployment/ResteasyCommonProcessor.java +++ b/extensions/resteasy-classic/resteasy-common/deployment/src/main/java/io/quarkus/resteasy/common/deployment/ResteasyCommonProcessor.java @@ -58,6 +58,7 @@ import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; import io.quarkus.deployment.util.ServiceUtil; +import io.quarkus.resteasy.common.runtime.ResteasyCommonConfig; import io.quarkus.resteasy.common.runtime.ResteasyInjectorFactoryRecorder; import io.quarkus.resteasy.common.runtime.config.ResteasyConfigBuilder; import io.quarkus.resteasy.common.runtime.providers.ServerFormUrlEncodedProvider; @@ -65,10 +66,6 @@ import io.quarkus.resteasy.common.spi.ResteasyDotNames; import io.quarkus.resteasy.common.spi.ResteasyJaxrsProviderBuildItem; import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.ConfigGroup; -import io.quarkus.runtime.annotations.ConfigItem; -import io.quarkus.runtime.annotations.ConfigRoot; -import io.quarkus.runtime.configuration.MemorySize; public class ResteasyCommonProcessor { @@ -102,31 +99,6 @@ public class ResteasyCommonProcessor { private ResteasyCommonConfig resteasyCommonConfig; - @ConfigRoot(name = "resteasy") - public static final class ResteasyCommonConfig { - /** - * Enable gzip support for REST - */ - public ResteasyCommonConfigGzip gzip; - } - - @ConfigGroup - public static final class ResteasyCommonConfigGzip { - /** - * If gzip is enabled - */ - @ConfigItem - public boolean enabled; - /** - * Maximum deflated file bytes size - *

- * If the limit is exceeded, Resteasy will return Response - * with status 413("Request Entity Too Large") - */ - @ConfigItem(defaultValue = "10M") - public MemorySize maxInput; - } - @BuildStep void addStaticInitConfigSourceProvider( Capabilities capabilities, @@ -164,7 +136,7 @@ void disableDefaultExceptionMapper(BuildProducer system @BuildStep void setupGzipProviders(BuildProducer providers) { // If GZIP support is enabled, enable it - if (resteasyCommonConfig.gzip.enabled) { + if (resteasyCommonConfig.gzip().enabled()) { providers.produce(new ResteasyJaxrsProviderBuildItem(AcceptEncodingGZIPFilter.class.getName())); providers.produce(new ResteasyJaxrsProviderBuildItem(GZIPDecodingInterceptor.class.getName())); providers.produce(new ResteasyJaxrsProviderBuildItem(GZIPEncodingInterceptor.class.getName())); diff --git a/extensions/resteasy-classic/resteasy-common/runtime/src/main/java/io/quarkus/resteasy/common/runtime/ResteasyCommonConfig.java b/extensions/resteasy-classic/resteasy-common/runtime/src/main/java/io/quarkus/resteasy/common/runtime/ResteasyCommonConfig.java new file mode 100644 index 0000000000000..aebebd4bc9ce1 --- /dev/null +++ b/extensions/resteasy-classic/resteasy-common/runtime/src/main/java/io/quarkus/resteasy/common/runtime/ResteasyCommonConfig.java @@ -0,0 +1,36 @@ +package io.quarkus.resteasy.common.runtime; + +import static io.quarkus.runtime.annotations.ConfigPhase.BUILD_AND_RUN_TIME_FIXED; + +import io.quarkus.runtime.annotations.ConfigRoot; +import io.quarkus.runtime.configuration.MemorySize; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigRoot(phase = BUILD_AND_RUN_TIME_FIXED) +@ConfigMapping(prefix = "quarkus.resteasy") +public interface ResteasyCommonConfig { + + /** + * Enable gzip support for REST + */ + ResteasyCommonConfigGzip gzip(); + + interface ResteasyCommonConfigGzip { + /** + * If gzip is enabled + */ + @WithDefault("false") + boolean enabled(); + + /** + * Maximum deflated file bytes size + *

+ * If the limit is exceeded, Resteasy will return Response + * with status 413("Request Entity Too Large") + */ + @WithDefault("10M") + MemorySize maxInput(); + } + +} diff --git a/extensions/resteasy-classic/resteasy-server-common/deployment/src/main/java/io/quarkus/resteasy/server/common/deployment/ResteasyServerCommonProcessor.java b/extensions/resteasy-classic/resteasy-server-common/deployment/src/main/java/io/quarkus/resteasy/server/common/deployment/ResteasyServerCommonProcessor.java index 3dedbf5ee5108..bee18043872b9 100644 --- a/extensions/resteasy-classic/resteasy-server-common/deployment/src/main/java/io/quarkus/resteasy/server/common/deployment/ResteasyServerCommonProcessor.java +++ b/extensions/resteasy-classic/resteasy-server-common/deployment/src/main/java/io/quarkus/resteasy/server/common/deployment/ResteasyServerCommonProcessor.java @@ -77,8 +77,8 @@ import io.quarkus.gizmo.Gizmo; import io.quarkus.jaxrs.spi.deployment.AdditionalJaxRsResourceMethodAnnotationsBuildItem; import io.quarkus.resteasy.common.deployment.JaxrsProvidersToRegisterBuildItem; -import io.quarkus.resteasy.common.deployment.ResteasyCommonProcessor.ResteasyCommonConfig; import io.quarkus.resteasy.common.runtime.QuarkusInjectorFactory; +import io.quarkus.resteasy.common.runtime.ResteasyCommonConfig; import io.quarkus.resteasy.common.spi.ResteasyDotNames; import io.quarkus.resteasy.server.common.runtime.QuarkusResteasyDeployment; import io.quarkus.resteasy.server.common.spi.AdditionalJaxRsResourceDefiningAnnotationBuildItem; @@ -421,9 +421,9 @@ public void build( deploymentCustomizer.getConsumer().accept(deployment); } - if (commonConfig.gzip.enabled) { + if (commonConfig.gzip().enabled()) { resteasyInitParameters.put(ResteasyContextParameters.RESTEASY_GZIP_MAX_INPUT, - Long.toString(commonConfig.gzip.maxInput.asLongValue())); + Long.toString(commonConfig.gzip().maxInput().asLongValue())); } resteasyInitParameters.put(ResteasyContextParameters.RESTEASY_UNWRAPPED_EXCEPTIONS, ArcUndeclaredThrowableException.class.getName()); diff --git a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyConfigurationMPConfig.java b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyConfigurationMPConfig.java index bdf70a82a1603..7c87d6b9426f5 100644 --- a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyConfigurationMPConfig.java +++ b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyConfigurationMPConfig.java @@ -67,6 +67,11 @@ public Set getInitParameterNames() { } private static Optional getGzipMaxInput(Config config) { + if (config.getOptionalValue("resteasy.gzip.max.input", String.class).isPresent()) { + // resteasy-specific properties have priority + return Optional.empty(); + } + Optional rawValue = config.getOptionalValue("quarkus.resteasy.gzip.max-input", MemorySize.class); if (rawValue.isEmpty()) { diff --git a/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qui/qui-ide-link.js b/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qui/qui-ide-link.js index 106ddd0143fd4..7e9c9bdc43a00 100644 --- a/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qui/qui-ide-link.js +++ b/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qui/qui-ide-link.js @@ -20,7 +20,7 @@ export class QuiIdeLink extends observeState(LitElement) { static properties = { fileName: {type: String}, lang: {type: String}, - lineNumber: {type: Number}, + lineNumber: {type: String}, stackTraceLine: {type: String}, _fontWeight: {type: String} }; @@ -30,7 +30,7 @@ export class QuiIdeLink extends observeState(LitElement) { this.stackTraceLine = null; this.fileName = null; this.lang = "java"; - this.lineNumber = 0; + this.lineNumber = "0"; this._fontWeight = "normal"; } @@ -55,7 +55,7 @@ export class QuiIdeLink extends observeState(LitElement) { if(givenClassName && givenClassName!== "" && this._checkIfStringStartsWith(givenClassName, devuiState.ideInfo.idePackages)){ this.fileName = givenClassName; this.lang = lang; - this.lineNumber = parseInt(lineNumber); + this.lineNumber = lineNumber; this._fontWeight = "bold"; } } diff --git a/extensions/web-dependency-locator/runtime/src/main/java/io/quarkus/webdependency/locator/runtime/WebDependencyLocatorRecorder.java b/extensions/web-dependency-locator/runtime/src/main/java/io/quarkus/webdependency/locator/runtime/WebDependencyLocatorRecorder.java index 1620a9c3a67fc..8db3d5202df95 100644 --- a/extensions/web-dependency-locator/runtime/src/main/java/io/quarkus/webdependency/locator/runtime/WebDependencyLocatorRecorder.java +++ b/extensions/web-dependency-locator/runtime/src/main/java/io/quarkus/webdependency/locator/runtime/WebDependencyLocatorRecorder.java @@ -2,6 +2,8 @@ import java.util.Map; +import org.jboss.logging.Logger; + import io.quarkus.runtime.annotations.Recorder; import io.vertx.core.Handler; import io.vertx.core.http.HttpHeaders; @@ -11,30 +13,38 @@ @Recorder public class WebDependencyLocatorRecorder { + private static final Logger LOG = Logger.getLogger(WebDependencyLocatorRecorder.class.getName()); + public Handler getHandler(String webDependenciesRootUrl, Map webDependencyNameToVersionMap) { return (event) -> { String path = event.normalizedPath(); if (path.startsWith(webDependenciesRootUrl)) { - String rest = path.substring(webDependenciesRootUrl.length()); - String webdep = rest.substring(0, rest.indexOf('/')); - if (webDependencyNameToVersionMap.containsKey(webdep)) { - // Check this is not the actual path (ex: /webjars/jquery/${jquery.version}/... - int endOfVersion = rest.indexOf('/', rest.indexOf('/') + 1); - if (endOfVersion == -1) { - endOfVersion = rest.length(); - } - String nextPathEntry = rest.substring(rest.indexOf('/') + 1, endOfVersion); - if (webDependencyNameToVersionMap.get(webdep) == null - || nextPathEntry.equals(webDependencyNameToVersionMap.get(webdep))) { - // go to the next handler (which should be the static resource handler, if one exists) - event.next(); + try { + String rest = path.substring(webDependenciesRootUrl.length()); + String webdep = rest.substring(0, rest.indexOf('/')); + if (webDependencyNameToVersionMap.containsKey(webdep)) { + // Check this is not the actual path (ex: /webjars/jquery/${jquery.version}/... + int endOfVersion = rest.indexOf('/', rest.indexOf('/') + 1); + if (endOfVersion == -1) { + endOfVersion = rest.length(); + } + String nextPathEntry = rest.substring(rest.indexOf('/') + 1, endOfVersion); + if (webDependencyNameToVersionMap.get(webdep) == null + || nextPathEntry.equals(webDependencyNameToVersionMap.get(webdep))) { + // go to the next handler (which should be the static resource handler, if one exists) + event.next(); + } else { + // reroute to the real resource + event.reroute(webDependenciesRootUrl + webdep + "/" + + webDependencyNameToVersionMap.get(webdep) + rest.substring(rest.indexOf('/'))); + } } else { - // reroute to the real resource - event.reroute(webDependenciesRootUrl + webdep + "/" - + webDependencyNameToVersionMap.get(webdep) + rest.substring(rest.indexOf('/'))); + event.next(); } - } else { + } catch (Throwable t) { + LOG.debug("Error while locating web jar " + path); + // See if someone else can handle this. event.next(); } } else { diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ClientEndpointTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ClientEndpointTest.java index 5a36ee3511326..617ea30bd31d8 100644 --- a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ClientEndpointTest.java +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ClientEndpointTest.java @@ -43,14 +43,15 @@ public class ClientEndpointTest { void testClient() throws InterruptedException { WebSocketClientConnection connection = connector .baseUri(uri) - .pathParam("name", "Lu") + // The value will be encoded automatically + .pathParam("name", "Lu=") .connectAndAwait(); - assertEquals("Lu", connection.pathParam("name")); + assertEquals("Lu=", connection.pathParam("name")); connection.sendTextAndAwait("Hi!"); assertTrue(ClientEndpoint.MESSAGE_LATCH.await(5, TimeUnit.SECONDS)); - assertEquals("Lu:Hello Lu!", ClientEndpoint.MESSAGES.get(0)); - assertEquals("Lu:Hi!", ClientEndpoint.MESSAGES.get(1)); + assertEquals("Lu=:Hello Lu=!", ClientEndpoint.MESSAGES.get(0)); + assertEquals("Lu=:Hi!", ClientEndpoint.MESSAGES.get(1)); connection.closeAndAwait(); assertTrue(ClientEndpoint.CLOSED_LATCH.await(5, TimeUnit.SECONDS)); diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ClientMessageErrorEndpoint.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ClientMessageErrorEndpoint.java new file mode 100644 index 0000000000000..8de5fa38add05 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ClientMessageErrorEndpoint.java @@ -0,0 +1,35 @@ +package io.quarkus.websockets.next.test.client; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; + +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocketClient; + +@WebSocketClient(path = "/endpoint") +public class ClientMessageErrorEndpoint { + + static final CountDownLatch MESSAGE_LATCH = new CountDownLatch(1); + + static final List MESSAGES = new CopyOnWriteArrayList<>(); + + static final CountDownLatch CLOSED_LATCH = new CountDownLatch(1); + + @OnTextMessage + void message(String message) { + if ("foo".equals(message)) { + throw new IllegalStateException("I cannot do it!"); + } else { + MESSAGES.add(message); + } + MESSAGE_LATCH.countDown(); + } + + @OnClose + void close() { + CLOSED_LATCH.countDown(); + } + +} \ No newline at end of file diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ClientOpenErrorEndpoint.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ClientOpenErrorEndpoint.java new file mode 100644 index 0000000000000..990c85bed80c7 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ClientOpenErrorEndpoint.java @@ -0,0 +1,37 @@ +package io.quarkus.websockets.next.test.client; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; + +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocketClient; + +@WebSocketClient(path = "/endpoint") +public class ClientOpenErrorEndpoint { + + static final CountDownLatch MESSAGE_LATCH = new CountDownLatch(1); + + static final List MESSAGES = new CopyOnWriteArrayList<>(); + + static final CountDownLatch CLOSED_LATCH = new CountDownLatch(1); + + @OnOpen + void open() { + throw new IllegalStateException("I cannot do it!"); + } + + @OnTextMessage + void message(String message) { + MESSAGES.add(message); + MESSAGE_LATCH.countDown(); + } + + @OnClose + void close() { + CLOSED_LATCH.countDown(); + } + +} \ No newline at end of file diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ServerEndpoint.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ServerEndpoint.java new file mode 100644 index 0000000000000..b2fbcbc19cd53 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/ServerEndpoint.java @@ -0,0 +1,24 @@ +package io.quarkus.websockets.next.test.client; + +import java.util.concurrent.CountDownLatch; + +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; + +@WebSocket(path = "/endpoint") +public class ServerEndpoint { + + static final CountDownLatch CLOSED_LATCH = new CountDownLatch(1); + + @OnTextMessage + String echo(String message) { + return message; + } + + @OnClose + void close() { + CLOSED_LATCH.countDown(); + } + +} \ No newline at end of file diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/UnhandledMessageFailureDefaultStrategyTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/UnhandledMessageFailureDefaultStrategyTest.java new file mode 100644 index 0000000000000..a1d80c81a021f --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/UnhandledMessageFailureDefaultStrategyTest.java @@ -0,0 +1,47 @@ +package io.quarkus.websockets.next.test.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.websockets.next.WebSocketClientConnection; +import io.quarkus.websockets.next.WebSocketConnector; + +public class UnhandledMessageFailureDefaultStrategyTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(ServerEndpoint.class, ClientMessageErrorEndpoint.class); + }); + + @Inject + WebSocketConnector connector; + + @TestHTTPResource("/") + URI testUri; + + @Test + void testError() throws InterruptedException { + WebSocketClientConnection connection = connector + .baseUri(testUri) + .connectAndAwait(); + connection.sendTextAndAwait("foo"); + assertTrue(ServerEndpoint.CLOSED_LATCH.await(5, TimeUnit.SECONDS)); + assertTrue(ClientMessageErrorEndpoint.CLOSED_LATCH.await(5, TimeUnit.SECONDS)); + assertTrue(connection.isClosed()); + assertEquals(WebSocketCloseStatus.INTERNAL_SERVER_ERROR.code(), connection.closeReason().getCode()); + assertTrue(ClientMessageErrorEndpoint.MESSAGES.isEmpty()); + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/UnhandledMessageFailureLogStrategyTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/UnhandledMessageFailureLogStrategyTest.java new file mode 100644 index 0000000000000..1b047d03e5bd7 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/UnhandledMessageFailureLogStrategyTest.java @@ -0,0 +1,46 @@ +package io.quarkus.websockets.next.test.client; + +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.net.URI; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.websockets.next.WebSocketClientConnection; +import io.quarkus.websockets.next.WebSocketConnector; + +public class UnhandledMessageFailureLogStrategyTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(ServerEndpoint.class, ClientMessageErrorEndpoint.class); + }).overrideConfigKey("quarkus.websockets-next.client.unhandled-failure-strategy", "log"); + + @Inject + WebSocketConnector connector; + + @TestHTTPResource("/") + URI testUri; + + @Test + void testError() throws InterruptedException { + WebSocketClientConnection connection = connector + .baseUri(testUri) + .connectAndAwait(); + connection.sendTextAndAwait("foo"); + assertFalse(connection.isClosed()); + connection.sendText("bar"); + assertTrue(ClientMessageErrorEndpoint.MESSAGE_LATCH.await(5, TimeUnit.SECONDS)); + assertEquals("bar", ClientMessageErrorEndpoint.MESSAGES.get(0)); + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/UnhandledOpenFailureDefaultStrategyTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/UnhandledOpenFailureDefaultStrategyTest.java new file mode 100644 index 0000000000000..decf21f2b1705 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/UnhandledOpenFailureDefaultStrategyTest.java @@ -0,0 +1,46 @@ +package io.quarkus.websockets.next.test.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.websockets.next.WebSocketClientConnection; +import io.quarkus.websockets.next.WebSocketConnector; + +public class UnhandledOpenFailureDefaultStrategyTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(ServerEndpoint.class, ClientOpenErrorEndpoint.class); + }); + + @Inject + WebSocketConnector connector; + + @TestHTTPResource("/") + URI testUri; + + @Test + void testError() throws InterruptedException { + WebSocketClientConnection connection = connector + .baseUri(testUri) + .connectAndAwait(); + assertTrue(ServerEndpoint.CLOSED_LATCH.await(5, TimeUnit.SECONDS)); + assertTrue(ClientOpenErrorEndpoint.CLOSED_LATCH.await(5, TimeUnit.SECONDS)); + assertTrue(connection.isClosed()); + assertEquals(WebSocketCloseStatus.INTERNAL_SERVER_ERROR.code(), connection.closeReason().getCode()); + assertTrue(ClientOpenErrorEndpoint.MESSAGES.isEmpty()); + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/UnhandledOpenFailureLogStrategyTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/UnhandledOpenFailureLogStrategyTest.java new file mode 100644 index 0000000000000..dc5f6d41504fa --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/UnhandledOpenFailureLogStrategyTest.java @@ -0,0 +1,47 @@ +package io.quarkus.websockets.next.test.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.websockets.next.WebSocketClientConnection; +import io.quarkus.websockets.next.WebSocketConnector; + +public class UnhandledOpenFailureLogStrategyTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(ServerEndpoint.class, ClientOpenErrorEndpoint.class); + }).overrideConfigKey("quarkus.websockets-next.client.unhandled-failure-strategy", "log"); + + @Inject + WebSocketConnector connector; + + @TestHTTPResource("/") + URI testUri; + + @Test + void testError() throws InterruptedException { + WebSocketClientConnection connection = connector + .baseUri(testUri) + .connectAndAwait(); + connection.sendTextAndAwait("foo"); + assertFalse(connection.isClosed()); + assertNull(connection.closeReason()); + assertTrue(ClientOpenErrorEndpoint.MESSAGE_LATCH.await(5, TimeUnit.SECONDS)); + assertEquals("foo", ClientOpenErrorEndpoint.MESSAGES.get(0)); + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/EchoMessageError.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/EchoMessageError.java new file mode 100644 index 0000000000000..3d52df32d1473 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/EchoMessageError.java @@ -0,0 +1,23 @@ +package io.quarkus.websockets.next.test.errors; + +import java.util.concurrent.CountDownLatch; + +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; + +@WebSocket(path = "/echo") +public class EchoMessageError { + + static final CountDownLatch MESSAGE_FAILURE_CALLED = new CountDownLatch(1); + + @OnTextMessage + String echo(String message) { + if ("foo".equals(message)) { + MESSAGE_FAILURE_CALLED.countDown(); + throw new IllegalStateException("I cannot do it!"); + } else { + return message; + } + } + +} \ No newline at end of file diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/EchoOpenError.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/EchoOpenError.java new file mode 100644 index 0000000000000..7a079a0eb45c2 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/EchoOpenError.java @@ -0,0 +1,25 @@ +package io.quarkus.websockets.next.test.errors; + +import java.util.concurrent.CountDownLatch; + +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; + +@WebSocket(path = "/echo") +public class EchoOpenError { + + static final CountDownLatch OPEN_CALLED = new CountDownLatch(1); + + @OnOpen + void open() { + OPEN_CALLED.countDown(); + throw new IllegalStateException("I cannot do it!"); + } + + @OnTextMessage + String echo(String message) { + return message; + } + +} \ No newline at end of file diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/UnhandledMessageFailureDefaultStrategyTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/UnhandledMessageFailureDefaultStrategyTest.java new file mode 100644 index 0000000000000..1207e6689277a --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/UnhandledMessageFailureDefaultStrategyTest.java @@ -0,0 +1,46 @@ +package io.quarkus.websockets.next.test.errors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.websockets.next.test.utils.WSClient; +import io.vertx.core.Vertx; + +public class UnhandledMessageFailureDefaultStrategyTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(EchoMessageError.class, WSClient.class); + }); + + @Inject + Vertx vertx; + + @TestHTTPResource("echo") + URI testUri; + + @Test + void testError() throws InterruptedException { + try (WSClient client = WSClient.create(vertx).connect(testUri)) { + client.sendAndAwait("foo"); + assertTrue(EchoMessageError.MESSAGE_FAILURE_CALLED.await(5, TimeUnit.SECONDS)); + Awaitility.await().atMost(Duration.ofSeconds(5)).until(() -> client.isClosed()); + assertEquals(WebSocketCloseStatus.INTERNAL_SERVER_ERROR.code(), client.closeStatusCode()); + } + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/UnhandledMessageFailureLogStrategyTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/UnhandledMessageFailureLogStrategyTest.java new file mode 100644 index 0000000000000..0061937345fcf --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/UnhandledMessageFailureLogStrategyTest.java @@ -0,0 +1,44 @@ +package io.quarkus.websockets.next.test.errors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.websockets.next.test.utils.WSClient; +import io.vertx.core.Vertx; + +public class UnhandledMessageFailureLogStrategyTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(EchoMessageError.class, WSClient.class); + }).overrideConfigKey("quarkus.websockets-next.server.unhandled-failure-strategy", "log"); + + @Inject + Vertx vertx; + + @TestHTTPResource("echo") + URI testUri; + + @Test + void testErrorDoesNotCloseConnection() throws InterruptedException { + try (WSClient client = WSClient.create(vertx).connect(testUri)) { + client.sendAndAwait("foo"); + assertTrue(EchoMessageError.MESSAGE_FAILURE_CALLED.await(5, TimeUnit.SECONDS)); + client.sendAndAwait("bar"); + client.waitForMessages(1); + assertEquals("bar", client.getLastMessage().toString()); + } + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/UnhandledOpenFailureDefaultStrategyTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/UnhandledOpenFailureDefaultStrategyTest.java new file mode 100644 index 0000000000000..61c712d005d86 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/UnhandledOpenFailureDefaultStrategyTest.java @@ -0,0 +1,45 @@ +package io.quarkus.websockets.next.test.errors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.websockets.next.test.utils.WSClient; +import io.vertx.core.Vertx; + +public class UnhandledOpenFailureDefaultStrategyTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(EchoOpenError.class, WSClient.class); + }); + + @Inject + Vertx vertx; + + @TestHTTPResource("echo") + URI testUri; + + @Test + void testError() throws InterruptedException { + try (WSClient client = WSClient.create(vertx).connect(testUri)) { + assertTrue(EchoOpenError.OPEN_CALLED.await(5, TimeUnit.SECONDS)); + Awaitility.await().atMost(Duration.ofSeconds(5)).until(() -> client.isClosed()); + assertEquals(WebSocketCloseStatus.INTERNAL_SERVER_ERROR.code(), client.closeStatusCode()); + } + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/UnhandledOpenFailureLogStrategyTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/UnhandledOpenFailureLogStrategyTest.java new file mode 100644 index 0000000000000..b704e8c551cde --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/errors/UnhandledOpenFailureLogStrategyTest.java @@ -0,0 +1,43 @@ +package io.quarkus.websockets.next.test.errors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.websockets.next.test.utils.WSClient; +import io.vertx.core.Vertx; + +public class UnhandledOpenFailureLogStrategyTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(EchoOpenError.class, WSClient.class); + }).overrideConfigKey("quarkus.websockets-next.server.unhandled-failure-strategy", "log"); + + @Inject + Vertx vertx; + + @TestHTTPResource("echo") + URI testUri; + + @Test + void testErrorDoesNotCloseConnection() throws InterruptedException { + try (WSClient client = WSClient.create(vertx).connect(testUri)) { + assertTrue(EchoOpenError.OPEN_CALLED.await(5, TimeUnit.SECONDS)); + client.sendAndAwait("foo"); + client.waitForMessages(1); + assertEquals("foo", client.getLastMessage().toString()); + } + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/AuthenticationExpiredTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/AuthenticationExpiredTest.java new file mode 100644 index 0000000000000..3351c71033053 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/AuthenticationExpiredTest.java @@ -0,0 +1,129 @@ +package io.quarkus.websockets.next.test.security; + +import static io.quarkus.websockets.next.test.security.SecurityTestBase.basicAuth; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.Authenticated; +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.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.websockets.next.CloseReason; +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; +import io.quarkus.websockets.next.test.utils.WSClient; +import io.smallrye.mutiny.Uni; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; + +public class AuthenticationExpiredTest { + + @Inject + Vertx vertx; + + @TestHTTPResource("end") + URI endUri; + + @BeforeAll + public static void setupUsers() { + TestIdentityController.resetRoles() + .add("admin", "admin", "admin") + .add("user", "user", "user"); + } + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot(root -> root.addClasses(Endpoint.class, TestIdentityProvider.class, + TestIdentityController.class, WSClient.class, ExpiredIdentityAugmentor.class, SecurityTestBase.class)); + + @Test + public void testConnectionClosedWhenAuthExpires() { + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("admin", "admin"), endUri); + + long threeSecondsFromNow = Duration.ofMillis(System.currentTimeMillis()).plusSeconds(3).toMillis(); + for (int i = 1; true; i++) { + if (client.isClosed()) { + break; + } else if (System.currentTimeMillis() > threeSecondsFromNow) { + Assertions.fail("Authentication expired, therefore connection should had been closed"); + } + client.sendAndAwaitReply("Hello #" + i + " from "); + } + + var receivedMessages = client.getMessages().stream().map(Buffer::toString).toList(); + assertTrue(receivedMessages.size() > 2, receivedMessages.toString()); + assertTrue(receivedMessages.contains("Hello #1 from admin"), receivedMessages.toString()); + assertTrue(receivedMessages.contains("Hello #2 from admin"), receivedMessages.toString()); + assertEquals(1008, client.closeStatusCode(), "Expected close status 1008, but got " + client.closeStatusCode()); + + Awaitility + .await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertTrue(Endpoint.CLOSED_MESSAGE.get() + .startsWith("Connection closed with reason 'Authentication expired'"))); + } + } + + @Singleton + public static class ExpiredIdentityAugmentor implements SecurityIdentityAugmentor { + + @Override + public Uni augment(SecurityIdentity securityIdentity, + AuthenticationRequestContext authenticationRequestContext) { + return Uni + .createFrom() + .item(QuarkusSecurityIdentity + .builder(securityIdentity) + .addAttribute("quarkus.identity.expire-time", expireIn2Seconds()) + .build()); + } + + private static long expireIn2Seconds() { + return Duration.ofMillis(System.currentTimeMillis()) + .plusSeconds(2) + .toSeconds(); + } + } + + @WebSocket(path = "/end") + public static class Endpoint { + + static final AtomicReference CLOSED_MESSAGE = new AtomicReference<>(); + + @Inject + SecurityIdentity currentIdentity; + + @Authenticated + @OnTextMessage + String echo(String message) { + return message + currentIdentity.getPrincipal().getName(); + } + + @OnClose + void close(CloseReason reason, WebSocketConnection connection) { + CLOSED_MESSAGE.set("Connection closed with reason '%s': %s".formatted(reason.getMessage(), connection)); + } + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/utils/WSClient.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/utils/WSClient.java index 773b9ab8d134f..955eb9c1b315c 100644 --- a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/utils/WSClient.java +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/utils/WSClient.java @@ -126,6 +126,10 @@ public boolean isClosed() { return socket.get().isClosed(); } + public int closeStatusCode() { + return socket.get().closeStatusCode(); + } + @Override public void close() { disconnect(); diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/BasicWebSocketConnector.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/BasicWebSocketConnector.java index 7ee5be65764e7..b1e21c9b12966 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/BasicWebSocketConnector.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/BasicWebSocketConnector.java @@ -1,6 +1,7 @@ package io.quarkus.websockets.next; import java.net.URI; +import java.net.URLEncoder; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -51,6 +52,9 @@ static BasicWebSocketConnector create() { /** * Set the path param. + *

+ * The value is encoded using {@link URLEncoder#encode(String, java.nio.charset.Charset)} before it's used to build the + * target URI. * * @param name * @param value diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/CloseReason.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/CloseReason.java index 55e100a9b9e7d..108c2d150b55b 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/CloseReason.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/CloseReason.java @@ -15,6 +15,8 @@ public class CloseReason { public static final CloseReason NORMAL = new CloseReason(WebSocketCloseStatus.NORMAL_CLOSURE.code()); + public static final CloseReason INTERNAL_SERVER_ERROR = new CloseReason(WebSocketCloseStatus.INTERNAL_SERVER_ERROR.code()); + private final int code; private final String message; diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/UnhandledFailureStrategy.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/UnhandledFailureStrategy.java new file mode 100644 index 0000000000000..bdfb1f17ad2be --- /dev/null +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/UnhandledFailureStrategy.java @@ -0,0 +1,20 @@ +package io.quarkus.websockets.next; + +/** + * The strategy used when an error occurs but no error handler can handle the failure. + */ +public enum UnhandledFailureStrategy { + /** + * Close the connection. + */ + CLOSE, + /** + * Log an error message. + */ + LOG, + /** + * No operation. + */ + NOOP; + +} \ No newline at end of file diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketClientConnection.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketClientConnection.java index 5151349c559d8..393ba422b7351 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketClientConnection.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketClientConnection.java @@ -27,7 +27,7 @@ public interface WebSocketClientConnection extends Sender, BlockingSender { /** * * @param name - * @return the actual value of the path parameter or null + * @return the value of the path parameter or {@code null} * @see WebSocketClient#path() */ String pathParam(String name); @@ -42,6 +42,12 @@ public interface WebSocketClientConnection extends Sender, BlockingSender { */ boolean isClosed(); + /** + * + * @return the close reason or {@code null} if the connection is not closed + */ + CloseReason closeReason(); + /** * * @return {@code true} if the WebSocket is open diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketConnection.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketConnection.java index be8acb1a93539..a63a3e2e5772e 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketConnection.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketConnection.java @@ -37,7 +37,7 @@ public interface WebSocketConnection extends Sender, BlockingSender { /** * * @param name - * @return the actual value of the path parameter or null + * @return the decoded value of the path parameter or {@code null} * @see WebSocket#path() */ String pathParam(String name); @@ -67,6 +67,12 @@ public interface WebSocketConnection extends Sender, BlockingSender { */ boolean isClosed(); + /** + * + * @return the close reason or {@code null} if the connection is not closed + */ + CloseReason closeReason(); + /** * * @return {@code true} if the WebSocket is open diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketConnector.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketConnector.java index 4b771a66c7833..257094e31fe23 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketConnector.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketConnector.java @@ -1,6 +1,7 @@ package io.quarkus.websockets.next; import java.net.URI; +import java.net.URLEncoder; import io.smallrye.common.annotation.CheckReturnValue; import io.smallrye.common.annotation.Experimental; @@ -28,6 +29,9 @@ public interface WebSocketConnector { /** * Set the path param. + *

+ * The value is encoded using {@link URLEncoder#encode(String, java.nio.charset.Charset)} before it's used to build the + * target URI. * * @param name * @param value diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsClientRuntimeConfig.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsClientRuntimeConfig.java index dff4780aa45c7..ecaf0bb169d0d 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsClientRuntimeConfig.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsClientRuntimeConfig.java @@ -40,4 +40,12 @@ public interface WebSocketsClientRuntimeConfig { */ Optional autoPingInterval(); + /** + * The strategy used when an error occurs but no error handler can handle the failure. + *

+ * By default, the connection is closed when an unhandled failure occurs. + */ + @WithDefault("close") + UnhandledFailureStrategy unhandledFailureStrategy(); + } diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsServerRuntimeConfig.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsServerRuntimeConfig.java index 28e9d284c2fce..43beffda35600 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsServerRuntimeConfig.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsServerRuntimeConfig.java @@ -46,4 +46,12 @@ public interface WebSocketsServerRuntimeConfig { */ Optional autoPingInterval(); + /** + * The strategy used when an error occurs but no error handler can handle the failure. + *

+ * By default, the connection is closed when an unhandled failure occurs. + */ + @WithDefault("close") + UnhandledFailureStrategy unhandledFailureStrategy(); + } 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 e8ed61d23620c..15980876612be 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 @@ -13,6 +13,8 @@ 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.UnhandledFailureStrategy; import io.quarkus.websockets.next.WebSocketException; import io.quarkus.websockets.next.runtime.WebSocketSessionContext.SessionContextState; import io.smallrye.mutiny.Multi; @@ -29,7 +31,7 @@ class Endpoints { static void initialize(Vertx vertx, ArcContainer container, Codecs codecs, WebSocketConnectionBase connection, WebSocketBase ws, String generatedEndpointClass, Optional autoPingInterval, - SecuritySupport securitySupport, Runnable onClose) { + SecuritySupport securitySupport, UnhandledFailureStrategy unhandledFailureStrategy, Runnable onClose) { Context context = vertx.getOrCreateContext(); @@ -75,7 +77,7 @@ public void handle(Void event) { LOG.debugf("@OnTextMessage callback consuming Multi completed: %s", connection); } else { - logFailure(r.cause(), + handleFailure(unhandledFailureStrategy, r.cause(), "Unable to complete @OnTextMessage callback consuming Multi", connection); } @@ -93,7 +95,7 @@ public void handle(Void event) { LOG.debugf("@OnBinaryMessage callback consuming Multi completed: %s", connection); } else { - logFailure(r.cause(), + handleFailure(unhandledFailureStrategy, r.cause(), "Unable to complete @OnBinaryMessage callback consuming Multi", connection); } @@ -102,7 +104,7 @@ public void handle(Void event) { }); } } else { - logFailure(r.cause(), "Unable to complete @OnOpen callback", connection); + handleFailure(unhandledFailureStrategy, r.cause(), "Unable to complete @OnOpen callback", connection); } }); } @@ -115,7 +117,8 @@ public void handle(Void event) { if (r.succeeded()) { LOG.debugf("@OnTextMessage callback consumed text message: %s", connection); } else { - logFailure(r.cause(), "Unable to consume text message in @OnTextMessage callback", + handleFailure(unhandledFailureStrategy, r.cause(), + "Unable to consume text message in @OnTextMessage callback", connection); } }); @@ -130,7 +133,8 @@ public void handle(Void event) { } catch (Throwable throwable) { endpoint.doOnError(throwable).subscribe().with( v -> LOG.debugf("Text message >> Multi: %s", connection), - t -> LOG.errorf(t, "Unable to send text message to Multi: %s", connection)); + t -> handleFailure(unhandledFailureStrategy, t, "Unable to send text message to Multi", + connection)); } finally { contextSupport.end(false); } @@ -144,7 +148,8 @@ public void handle(Void event) { if (r.succeeded()) { LOG.debugf("@OnBinaryMessage callback consumed binary message: %s", connection); } else { - logFailure(r.cause(), "Unable to consume binary message in @OnBinaryMessage callback", + handleFailure(unhandledFailureStrategy, r.cause(), + "Unable to consume binary message in @OnBinaryMessage callback", connection); } }); @@ -159,7 +164,8 @@ public void handle(Void event) { } catch (Throwable throwable) { endpoint.doOnError(throwable).subscribe().with( v -> LOG.debugf("Binary message >> Multi: %s", connection), - t -> LOG.errorf(t, "Unable to send binary message to Multi: %s", connection)); + t -> handleFailure(unhandledFailureStrategy, t, "Unable to send binary message to Multi", + connection)); } finally { contextSupport.end(false); } @@ -171,7 +177,8 @@ public void handle(Void event) { if (r.succeeded()) { LOG.debugf("@OnPongMessage callback consumed text message: %s", connection); } else { - logFailure(r.cause(), "Unable to consume text message in @OnPongMessage callback", connection); + handleFailure(unhandledFailureStrategy, r.cause(), + "Unable to consume text message in @OnPongMessage callback", connection); } }); }); @@ -198,8 +205,10 @@ public void handle(Void event) { if (r.succeeded()) { LOG.debugf("@OnClose callback completed: %s", connection); } else { - logFailure(r.cause(), "Unable to complete @OnClose callback", connection); + handleFailure(unhandledFailureStrategy, r.cause(), "Unable to complete @OnClose callback", + connection); } + securitySupport.onClose(); onClose.run(); if (timerId != null) { vertx.cancelTimer(timerId); @@ -218,14 +227,30 @@ public void handle(Throwable t) { public void handle(Void event) { endpoint.doOnError(t).subscribe().with( v -> LOG.debugf("Error [%s] processed: %s", t.getClass(), connection), - t -> LOG.errorf(t, "Unhandled error occurred: %s", t.toString(), - connection)); + t -> handleFailure(unhandledFailureStrategy, t, "Unhandled error occurred", connection)); } }); } }); } + private static void handleFailure(UnhandledFailureStrategy strategy, Throwable cause, String message, + WebSocketConnectionBase connection) { + switch (strategy) { + case CLOSE -> closeConnection(cause, connection); + case LOG -> logFailure(cause, message, connection); + case NOOP -> LOG.tracef("Unhandled failure ignored: %s", connection); + default -> throw new IllegalArgumentException("Unexpected strategy: " + strategy); + } + } + + private static void closeConnection(Throwable cause, WebSocketConnectionBase connection) { + connection.close(CloseReason.INTERNAL_SERVER_ERROR).subscribe().with( + v -> LOG.debugf("Connection closed due to unhandled failure %s: %s", cause, connection), + t -> LOG.errorf("Unable to close connection [%s] due to unhandled failure [%s]: %s", connection.id(), cause, + t)); + } + private static void logFailure(Throwable throwable, String message, WebSocketConnectionBase connection) { if (isWebSocketIsClosedFailure(throwable, connection)) { LOG.debugf(throwable, diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/SecuritySupport.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/SecuritySupport.java index 8ec115e085e70..eeb5f5a5ad342 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/SecuritySupport.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/SecuritySupport.java @@ -1,22 +1,36 @@ package io.quarkus.websockets.next.runtime; import java.util.Objects; +import java.util.concurrent.TimeUnit; import jakarta.enterprise.inject.Instance; +import org.jboss.logging.Logger; + import io.quarkus.security.identity.CurrentIdentityAssociation; import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.websockets.next.CloseReason; +import io.vertx.core.Vertx; public class SecuritySupport { - static final SecuritySupport NOOP = new SecuritySupport(null, null); + private static final Logger LOG = Logger.getLogger(SecuritySupport.class); + static final SecuritySupport NOOP = new SecuritySupport(null, null, null, null); private final Instance currentIdentity; private final SecurityIdentity identity; + private final Runnable onClose; - SecuritySupport(Instance currentIdentity, SecurityIdentity identity) { + SecuritySupport(Instance currentIdentity, SecurityIdentity identity, Vertx vertx, + WebSocketConnectionImpl connection) { this.currentIdentity = currentIdentity; - this.identity = currentIdentity != null ? Objects.requireNonNull(identity) : identity; + if (this.currentIdentity != null) { + this.identity = Objects.requireNonNull(identity); + this.onClose = closeConnectionWhenIdentityExpired(vertx, connection, this.identity); + } else { + this.identity = null; + this.onClose = null; + } } /** @@ -29,4 +43,25 @@ void start() { } } + void onClose() { + if (onClose != null) { + onClose.run(); + } + } + + private static Runnable closeConnectionWhenIdentityExpired(Vertx vertx, WebSocketConnectionImpl connection, + SecurityIdentity identity) { + if (identity.getAttribute("quarkus.identity.expire-time") instanceof Long expireAt) { + long timerId = vertx.setTimer(TimeUnit.SECONDS.toMillis(expireAt) - System.currentTimeMillis(), + ignored -> connection + .close(new CloseReason(1008, "Authentication expired")) + .subscribe() + .with( + v -> LOG.tracef("Closed connection due to expired authentication: %s", connection), + e -> LOG.errorf("Unable to close connection [%s] after authentication " + + "expired due to unhandled failure: %s", connection, e))); + return () -> vertx.cancelTimer(timerId); + } + return null; + } } diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionBase.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionBase.java index e722da795ede8..00ae0dc9e0d1f 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionBase.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionBase.java @@ -125,6 +125,6 @@ public CloseReason closeReason() { if (ws.isClosed()) { return new CloseReason(ws.closeStatusCode(), ws.closeReason()); } - throw new IllegalStateException("Connection is not closed"); + return null; } } diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectorBase.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectorBase.java index 4059996cd8369..728850f3083fd 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectorBase.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectorBase.java @@ -1,6 +1,8 @@ package io.quarkus.websockets.next.runtime; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -121,7 +123,7 @@ String replacePathParameters(String path) { if (val == null) { throw new WebSocketClientException("Unable to obtain the path param for: " + paramName); } - m.appendReplacement(sb, val); + m.appendReplacement(sb, URLEncoder.encode(val, StandardCharsets.UTF_8)); } m.appendTail(sb); return path.startsWith("/") ? sb.toString() : "/" + sb.toString(); diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectorImpl.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectorImpl.java index d6281e5da71f4..ceaeab285dd80 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectorImpl.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectorImpl.java @@ -92,7 +92,7 @@ public Uni connect() { .setPort(serverEndpointUri.getPort()); StringBuilder uri = new StringBuilder(); if (serverEndpointUri.getPath() != null) { - uri.append(serverEndpointUri.getPath()); + uri.append(serverEndpointUri.getRawPath()); } if (serverEndpointUri.getQuery() != null) { uri.append("?").append(serverEndpointUri.getQuery()); @@ -116,6 +116,7 @@ public Uni connect() { Endpoints.initialize(vertx, Arc.container(), codecs, connection, ws, clientEndpoint.generatedEndpointClass, config.autoPingInterval(), SecuritySupport.NOOP, + config.unhandledFailureStrategy(), () -> { connectionManager.remove(clientEndpoint.generatedEndpointClass, connection); client.close(); diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketServerRecorder.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketServerRecorder.java index 9384f8d60fc47..2878f921d680c 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketServerRecorder.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketServerRecorder.java @@ -90,8 +90,6 @@ public Handler createEndpointHandler(String generatedEndpointCla @Override public void handle(RoutingContext ctx) { - SecuritySupport securitySupport = initializeSecuritySupport(container, ctx); - Future future = ctx.request().toWebSocket(); future.onSuccess(ws -> { Vertx vertx = VertxCoreRecorder.getVertx().get(); @@ -101,22 +99,25 @@ public void handle(RoutingContext ctx) { connectionManager.add(generatedEndpointClass, connection); LOG.debugf("Connection created: %s", connection); + SecuritySupport securitySupport = initializeSecuritySupport(container, ctx, vertx, connection); + Endpoints.initialize(vertx, container, codecs, connection, ws, generatedEndpointClass, - config.autoPingInterval(), securitySupport, + config.autoPingInterval(), securitySupport, config.unhandledFailureStrategy(), () -> connectionManager.remove(generatedEndpointClass, connection)); }); } }; } - SecuritySupport initializeSecuritySupport(ArcContainer container, RoutingContext ctx) { + SecuritySupport initializeSecuritySupport(ArcContainer container, RoutingContext ctx, Vertx vertx, + WebSocketConnectionImpl connection) { Instance currentIdentityAssociation = container.select(CurrentIdentityAssociation.class); if (currentIdentityAssociation.isResolvable()) { // Security extension is present // Obtain the current security identity from the handshake request QuarkusHttpUser user = (QuarkusHttpUser) ctx.user(); if (user != null) { - return new SecuritySupport(currentIdentityAssociation, user.getSecurityIdentity()); + return new SecuritySupport(currentIdentityAssociation, user.getSecurityIdentity(), vertx, connection); } } return SecuritySupport.NOOP; diff --git a/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/util/Encode.java b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/util/Encode.java index 2d3c74996355d..7536e366e5b9b 100644 --- a/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/util/Encode.java +++ b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/util/Encode.java @@ -95,7 +95,9 @@ public class Encode { case '.': case '_': case '~': + continue; case '?': + queryNameValueEncoding[i] = "%3F"; continue; case ' ': queryNameValueEncoding[i] = "+"; diff --git a/independent-projects/resteasy-reactive/common/runtime/src/test/java/org/jboss/resteasy/reactive/common/util/EncodeTest.java b/independent-projects/resteasy-reactive/common/runtime/src/test/java/org/jboss/resteasy/reactive/common/util/EncodeTest.java index 9e057ce31126f..c53115d687635 100644 --- a/independent-projects/resteasy-reactive/common/runtime/src/test/java/org/jboss/resteasy/reactive/common/util/EncodeTest.java +++ b/independent-projects/resteasy-reactive/common/runtime/src/test/java/org/jboss/resteasy/reactive/common/util/EncodeTest.java @@ -15,4 +15,11 @@ void encodeEmoji() { assertEquals(encodedEmoji, Encode.encodePath(emoji)); assertEquals(encodedEmoji, Encode.encodeQueryParam(emoji)); } + + @Test + void encodeQuestionMarkQueryParameterValue() { + String uriQueryValue = "bar?a=b"; + String encoded = URLEncoder.encode(uriQueryValue, StandardCharsets.UTF_8); + assertEquals(encoded, Encode.encodeQueryParam(uriQueryValue)); + } } diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/util/FileUtils.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/util/FileUtils.java index 609f4431d333f..fb8f7d9796e46 100644 --- a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/util/FileUtils.java +++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/util/FileUtils.java @@ -81,6 +81,12 @@ public static Optional read(Class clazz, Path path, MessageWriter log) } catch (Exception e) { log.warn("[Quarkus build analytics] Could not read {}", path.toString(), e); return Optional.empty(); + } catch (Throwable t) { + log.error("[Quarkus build analytics] Unexpected error reading class " + t.getClass().getName() + + " from path: " + path.toString() + + ". Got message: " + t.getMessage() + + ". Attempting to continue..."); + return Optional.empty(); } } }