diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 9532c778da65a..bdd409a15a4c2 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -223,7 +223,7 @@ 0.0.6 0.1.3 2.12.0 - 0.8.9 + 0.8.11 1.0.0 3.0.0 2.12.3 diff --git a/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/UpxCompressionBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/UpxCompressionBuildStep.java index 237e18cafa344..18807695b9529 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/UpxCompressionBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/UpxCompressionBuildStep.java @@ -16,6 +16,7 @@ import org.jboss.logging.Logger; import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.pkg.NativeConfig; import io.quarkus.deployment.pkg.builditem.ArtifactResultBuildItem; import io.quarkus.deployment.pkg.builditem.NativeImageBuildItem; @@ -34,6 +35,7 @@ public class UpxCompressionBuildStep { */ private static final String PATH = "PATH"; + @BuildStep(onlyIf = NativeBuild.class) public void compress(NativeConfig nativeConfig, NativeImageRunnerBuildItem nativeImageRunner, NativeImageBuildItem image, BuildProducer upxCompressedProducer, diff --git a/devtools/cli/src/main/java/io/quarkus/cli/build/GradleRunner.java b/devtools/cli/src/main/java/io/quarkus/cli/build/GradleRunner.java index cd7aff6f5b4c0..63ecaba037938 100644 --- a/devtools/cli/src/main/java/io/quarkus/cli/build/GradleRunner.java +++ b/devtools/cli/src/main/java/io/quarkus/cli/build/GradleRunner.java @@ -156,6 +156,7 @@ public Integer updateProject(TargetQuarkusVersionGroup targetQuarkusVersion, Rew args.add("-PquarkusPluginVersion=" + ToolsUtils.getGradlePluginVersion(props)); args.add("--console"); args.add("plain"); + args.add("--no-daemon"); args.add("--stacktrace"); args.add("quarkusUpdate"); if (!StringUtil.isNullOrEmpty(targetQuarkusVersion.platformVersion)) { diff --git a/docs/src/main/asciidoc/security-openid-connect-client.adoc b/docs/src/main/asciidoc/security-openid-connect-client.adoc index bc8197a89763b..6594912214038 100644 --- a/docs/src/main/asciidoc/security-openid-connect-client.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-client.adoc @@ -12,9 +12,7 @@ include::_attributes.adoc[] Learn how to use OpenID Connect (OIDC) and OAuth2 clients with filters to get, refresh, and propagate access tokens in your applications. -This approach uses an OIDC token propagation Reactive filter to propagate the incoming bearer access tokens. - -For more information about `Oidc Client` and `Token Propagation` support in Quarkus, see the xref:security-openid-connect-client-reference.adoc[OpenID Connect (OIDC) and OAuth2 client and filters reference guide]. +For more information about `OIDC Client` and `Token Propagation` support in Quarkus, see the xref:security-openid-connect-client-reference.adoc[OpenID Connect (OIDC) and OAuth2 client and filters reference guide]. To protect your applications by using Bearer Token Authorization, see the xref:security-oidc-bearer-token-authentication.adoc[OpenID Connect (OIDC) Bearer token authentication] guide. @@ -27,20 +25,26 @@ include::{includes}/prerequisites.adoc[] == Architecture In this example, an application is built with two Jakarta REST resources, `FrontendResource` and `ProtectedResource`. -Here, `FrontendResource` uses one of two methods to propagate access tokens to `ProtectedResource`: +Here, `FrontendResource` uses one of three methods to propagate access tokens to `ProtectedResource`: -* It can get a token by using an OIDC token propagation Reactive filter before propagating it. -* It can use an OIDC token propagation Reactive filter to propagate the incoming access token. +* It can get a token by using an OIDC client filter before propagating it. +* It can get a token by using a programmatically created OIDC client and propagate it by passing it to a REST client method as an HTTP `Authorization` header value. +* It can use an OIDC token propagation filter to propagate the incoming access token. -`FrontendResource` has four endpoints: +`FrontendResource` has eight endpoints: * `/frontend/user-name-with-oidc-client-token` * `/frontend/admin-name-with-oidc-client-token` +* `/frontend/user-name-with-oidc-client-token-header-param` +* `/frontend/admin-name-with-oidc-client-token-header-param` +* `/frontend/user-name-with-oidc-client-token-header-param-blocking` +* `/frontend/admin-name-with-oidc-client-token-header-param-blocking` * `/frontend/user-name-with-propagated-token` * `/frontend/admin-name-with-propagated-token` -`FrontendResource` uses a REST Client with an OIDC token propagation Reactive filter to get and propagate an access token to `ProtectedResource` when either `/frontend/user-name-with-oidc-client-token` or `/frontend/admin-name-with-oidc-client-token` is called. -Also, `FrontendResource` uses a REST Client with `OpenID Connect Token Propagation Reactive Filter` to propagate the current incoming access token to `ProtectedResource` when either `/frontend/user-name-with-propagated-token` or `/frontend/admin-name-with-propagated-token` is called. +When either `/frontend/user-name-with-oidc-client-token` or `/frontend/admin-name-with-oidc-client-token` endpoint is called, `FrontendResource` uses a REST client with an OIDC client filter to get and propagate an access token to `ProtectedResource` . +When either `/frontend/user-name-with-oidc-client-token-header-param` or `/frontend/admin-name-with-oidc-client-token-header-param` endpoint is called, `FrontendResource` uses a programmatically created OIDC client to get and propagate an access token to `ProtectedResource` by passing it to a REST client method as an HTTP `Authorization` header value. +When either `/frontend/user-name-with-propagated-token` or `/frontend/admin-name-with-propagated-token` endpoint is called, `FrontendResource` uses a REST client with `OIDC Token Propagation Filter` to propagate the current incoming access token to `ProtectedResource`. `ProtectedResource` has two endpoints: @@ -68,14 +72,14 @@ Create a new project with the following command: :create-app-extensions: oidc,rest-client-oidc-filter,rest-client-oidc-token-propagation,rest include::{includes}/devtools/create-app.adoc[] -This command generates a Maven project, importing the `oidc`, `rest-client-oidc-filter`, `rest-client-oidc-token-propagation`, and `rest` extensions. +It generates a Maven project, importing the `oidc`, `rest-client-oidc-filter`, `rest-client-oidc-token-propagation`, and `rest` extensions. If you already have your Quarkus project configured, you can add these extensions to your project by running the following command in your project base directory: :add-extension-extensions: oidc,rest-client-oidc-filter,rest-client-oidc-token-propagation,rest include::{includes}/devtools/extension-add.adoc[] -This command adds the following extensions to your build file: +It adds the following extensions to your build file: [source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] .pom.xml @@ -151,9 +155,13 @@ public class ProtectedResource { `ProtectedResource` returns a name from both `userName()` and `adminName()` methods. The name is extracted from the current `JsonWebToken`. -Next, add two REST clients, `OidcClientRequestReactiveFilter` and `AccessTokenRequestReactiveFilter`, which `FrontendResource` uses to call `ProtectedResource`. +Next, add three REST clients: + +1. `RestClientWithOidcClientFilter`, which uses an OIDC client filter provided by the `quarkus-rest-client-oidc-filter` extension to get and propagate an access token. +2. `RestClientWithTokenHeaderParam`, which accepts a token already acquired by the programmatically created OidcClient as an HTTP `Authorization` header value. +3. `RestClientWithTokenPropagationFilter`, which uses an OIDC token propagation filter provided by the `quarkus-rest-client-oidc-token-propagation` extension to get and propagate an access token. -Add the `OidcClientRequestReactiveFilter` REST Client: +Add the `RestClientWithOidcClientFilter` REST client: [source,java] ---- @@ -166,11 +174,11 @@ import jakarta.ws.rs.Produces; import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; -import io.quarkus.oidc.client.reactive.filter.OidcClientRequestReactiveFilter; +import io.quarkus.oidc.client.filter.OidcClientFilter; import io.smallrye.mutiny.Uni; @RegisterRestClient -@RegisterProvider(OidcClientRequestReactiveFilter.class) +@OidcClientFilter <1> @Path("/") public interface RestClientWithOidcClientFilter { @@ -185,10 +193,40 @@ public interface RestClientWithOidcClientFilter { Uni getAdminName(); } ---- +<1> Register an OIDC client filter with the REST client to get and propagate the tokens. + +Add the `RestClientWithTokenHeaderParam` REST client: + +[source,java] +---- +package org.acme.security.openid.connect.client; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; + +@RegisterRestClient +@Path("/") +public interface RestClientWithTokenHeaderParam { -The `RestClientWithOidcClientFilter` interface depends on `OidcClientRequestReactiveFilter` to get and propagate the tokens. + @GET + @Produces("text/plain") + @Path("userName") + Uni getUserName(@HeaderParam("Authorization") String authorization); <1> + + @GET + @Produces("text/plain") + @Path("adminName") + Uni getAdminName(@HeaderParam("Authorization") String authorization); <1> +} +---- +<1> `RestClientWithTokenHeaderParam` REST client expects that the tokens will be passed to it as HTTP `Authorization` header values. -Add the `AccessTokenRequestReactiveFilter` REST Client: +Add the `RestClientWithTokenPropagationFilter` REST client: [source,java] ---- @@ -201,11 +239,12 @@ import jakarta.ws.rs.Produces; import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; -import io.quarkus.oidc.token.propagation.reactive.AccessTokenRequestReactiveFilter; +import io.quarkus.oidc.token.propagation.AccessToken; + import io.smallrye.mutiny.Uni; @RegisterRestClient -@RegisterProvider(AccessTokenRequestReactiveFilter.class) +@AccessToken <1> @Path("/") public interface RestClientWithTokenPropagationFilter { @@ -220,12 +259,63 @@ public interface RestClientWithTokenPropagationFilter { Uni getAdminName(); } ---- +<1> Register an OIDC token propagation filter with the REST client to propagate the incoming already-existing tokens. + +IMPORTANT: Do not use the `RestClientWithOidcClientFilter` and `RestClientWithTokenPropagationFilter` interfaces in the same REST client because they can conflict, leading to issues. +For example, the OIDC client filter can override the token from the OIDC token propagation filter, or the propagation filter might not work correctly if it attempts to propagate a token when none is available, expecting the OIDC client filter to obtain a new token instead. + +Also, add `OidcClientCreator` to create an OIDC client programmatically at startup. `OidcClientCreator` supports `RestClientWithTokenHeaderParam` REST client calls: + +[source,java] +---- +package org.acme.security.openid.connect.client; + +import java.util.Map; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.quarkus.oidc.client.OidcClient; +import io.quarkus.oidc.client.OidcClientConfig; +import io.quarkus.oidc.client.OidcClientConfig.Grant.Type; +import io.quarkus.oidc.client.OidcClients; +import io.quarkus.runtime.StartupEvent; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +@ApplicationScoped +public class OidcClientCreator { + + @Inject + OidcClients oidcClients; <1> + @ConfigProperty(name = "quarkus.oidc.auth-server-url") + String oidcProviderAddress; + + private volatile OidcClient oidcClient; + + public void startup(@Observes StartupEvent event) { + createOidcClient().subscribe().with(client -> {oidcClient = client;}); + } -The `RestClientWithTokenPropagationFilter` interface depends on `AccessTokenRequestReactiveFilter` to propagate the incoming already-existing tokens. + public OidcClient getOidcClient() { + return oidcClient; + } -Note that both `RestClientWithOidcClientFilter` and `RestClientWithTokenPropagationFilter` interfaces are the same. -This is because combining `OidcClientRequestReactiveFilter` and `AccessTokenRequestReactiveFilter` on the same REST Client causes side effects because both filters can interfere with each other. -For example, `OidcClientRequestReactiveFilter` can override the token propagated by `AccessTokenRequestReactiveFilter`, or `AccessTokenRequestReactiveFilter` can fail if it is called when no token is available to propagate and `OidcClientRequestReactiveFilter` is expected to get a new token instead. + private Uni createOidcClient() { + OidcClientConfig cfg = new OidcClientConfig(); + cfg.setId("myclient"); + cfg.setAuthServerUrl(oidcProviderAddress); + cfg.setClientId("backend-service"); + cfg.getCredentials().setSecret("secret"); + cfg.getGrant().setType(Type.PASSWORD); + cfg.setGrantOptions(Map.of("password", + Map.of("username", "alice", "password", "alice"))); + return oidcClients.newClient(cfg); + } +} +---- +<1> `OidcClients` can be used to retrieve the already initialized, named OIDC clients and create new OIDC clients on demand. Now, finish creating the application by adding `FrontendResource`: @@ -238,6 +328,9 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import io.quarkus.oidc.client.Tokens; +import io.quarkus.oidc.client.runtime.TokensHelper; + import org.eclipse.microprofile.rest.client.inject.RestClient; import io.smallrye.mutiny.Uni; @@ -246,44 +339,86 @@ import io.smallrye.mutiny.Uni; public class FrontendResource { @Inject @RestClient - RestClientWithOidcClientFilter restClientWithOidcClientFilter; + RestClientWithOidcClientFilter restClientWithOidcClientFilter; <1> + + @Inject + @RestClient + RestClientWithTokenPropagationFilter restClientWithTokenPropagationFilter; <2> + @Inject + OidcClientCreator oidcClientCreator; + TokensHelper tokenHelper = new TokensHelper(); <5> @Inject @RestClient - RestClientWithTokenPropagationFilter restClientWithTokenPropagationFilter; + RestClientWithHeaderTokenParam restClientWithHeaderTokenParam; <3> @GET @Path("user-name-with-oidc-client-token") @Produces("text/plain") - public Uni getUserNameWithOidcClientToken() { + public Uni getUserNameWithOidcClientToken() { <1> return restClientWithOidcClientFilter.getUserName(); } @GET @Path("admin-name-with-oidc-client-token") @Produces("text/plain") - public Uni getAdminNameWithOidcClientToken() { - return restClientWithOidcClientFilter.getAdminName(); + public Uni getAdminNameWithOidcClientToken() { <1> + return restClientWithOidcClientFilter.getAdminName(); } @GET @Path("user-name-with-propagated-token") @Produces("text/plain") - public Uni getUserNameWithPropagatedToken() { + public Uni getUserNameWithPropagatedToken() { <2> return restClientWithTokenPropagationFilter.getUserName(); } @GET @Path("admin-name-with-propagated-token") @Produces("text/plain") - public Uni getAdminNameWithPropagatedToken() { + public Uni getAdminNameWithPropagatedToken() { <2> return restClientWithTokenPropagationFilter.getAdminName(); } + + @GET + @Path("user-name-with-oidc-client-token-header-param") + @Produces("text/plain") + public Uni getUserNameWithOidcClientTokenHeaderParam() { <3> + return tokenHelper.getTokens(oidcClientCreator.getOidcClient()).onItem() + .transformToUni(tokens -> restClientWithTokenHeaderParam.getUserName("Bearer " + tokens.getAccessToken())); + } + + @GET + @Path("admin-name-with-oidc-client-token-header-param") + @Produces("text/plain") + public Uni getAdminNameWithOidcClientTokenHeaderParam() { <3> + return tokenHelper.getTokens(oidcClientCreator.getOidcClient()).onItem() + .transformToUni(tokens -> restClientWithTokenHeaderParam.getAdminName("Bearer " + tokens.getAccessToken())); + } + + @GET + @Path("user-name-with-oidc-client-token-header-param-blocking") + @Produces("text/plain") + public String getUserNameWithOidcClientTokenHeaderParamBlocking() { <4> + Tokens tokens = tokenHelper.getTokens(oidcClientCreator.getOidcClient()).await().indefinitely(); + return restClientWithTokenHeaderParam.getUserName("Bearer " + tokens.getAccessToken()).await().indefinitely(); + } + + @GET + @Path("admin-name-with-oidc-client-token-header-param-blocking") + @Produces("text/plain") + public String getAdminNameWithOidcClientTokenHeaderParamBlocking() { <4> + Tokens tokens = tokenHelper.getTokens(oidcClientCreator.getOidcClient()).await().indefinitely(); + return restClientWithTokenHeaderParam.getAdminName("Bearer " + tokens.getAccessToken()).await().indefinitely(); + } + } ---- - -`FrontendResource` uses REST Client with an OIDC token propagation Reactive filter to get and propagate an access token to `ProtectedResource` when either `/frontend/user-name-with-oidc-client-token` or `/frontend/admin-name-with-oidc-client-token` is called. -Also, `FrontendResource` uses REST Client with `OpenID Connect Token Propagation Reactive Filter` to propagate the current incoming access token to `ProtectedResource` when either `/frontend/user-name-with-propagated-token` or `/frontend/admin-name-with-propagated-token` is called. +<1> `FrontendResource` uses the injected `RestClientWithOidcClientFilter` REST client with the OIDC client filter to get and propagate an access token to `ProtectedResource` when either `/frontend/user-name-with-oidc-client-token` or `/frontend/admin-name-with-oidc-client-token` is called. +<2> `FrontendResource` uses the injected `RestClientWithTokenPropagationFilter` REST client with the OIDC token propagation filter to propagate the current incoming access token to `ProtectedResource` when either `/frontend/user-name-with-propagated-token` or `/frontend/admin-name-with-propagated-token` is called. +<3> `FrontendResource` uses the programmatically created OIDC client to get and propagate an access token to `ProtectedResource` by passing it directly to the injected `RestClientWithHeaderTokenParam` REST client's method as an HTTP `Authorization` header value, when either `/frontend/user-name-with-oidc-client-token-header-param` or `/frontend/admin-name-with-oidc-client-token-header-param` is called. +<4> Sometimes, one may have to acquire tokens in a blocking manner before propagating them with the REST client. This example shows how to acquire the tokens in such cases. +<5> `io.quarkus.oidc.client.runtime.TokensHelper` is a useful tool when OIDC client is used directly, without the OIDC client filter. To use `TokensHelper`, pass OIDC Client to it to get the tokens and `TokensHelper` acquires the tokens and refreshes them if necessary in a thread-safe way. Finally, add a Jakarta REST `ExceptionMapper`: @@ -309,7 +444,7 @@ public class FrontendExceptionMapper implements ExceptionMapper imageName, OptionalInt fixedE super(DockerImageName .parse(imageName.orElseGet(() -> ConfigureUtil.getDefaultImageNameFor("postgresql"))) .asCompatibleSubstituteFor(DockerImageName.parse(PostgreSQLContainer.IMAGE))); + this.fixedExposedPort = fixedExposedPort; this.useSharedNetwork = useSharedNetwork; + // Workaround for https://github.com/testcontainers/testcontainers-java/issues/4799. // The motivation of this custom wait strategy is that Testcontainers fails to start a Postgresql database when it // has been already initialized. // This custom wait strategy will work fine regardless of the state of the Postgresql database. // More information in the issue ticket in Testcontainers. - this.waitStrategy = new LogMessageWaitStrategy() - .withRegEx("(" + READY_REGEX + ")?(" + SKIPPING_INITIALIZATION_REGEX + ")?") - .withTimes(2) + + // Added Wait.forListeningPort() for https://github.com/quarkusio/quarkus/issues/25682 + // as suggested by https://github.com/testcontainers/testcontainers-java/pull/6309 + this.waitStrategy = new WaitAllStrategy() + .withStrategy(Wait.forLogMessage("(" + READY_REGEX + ")?(" + SKIPPING_INITIALIZATION_REGEX + ")?", 2)) + .withStrategy(Wait.forListeningPort()) .withStartupTimeout(Duration.of(60L, ChronoUnit.SECONDS)); } diff --git a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/HttpCommonTags.java b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/HttpCommonTags.java index 656736a6d345c..7cab35f4f0446 100644 --- a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/HttpCommonTags.java +++ b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/HttpCommonTags.java @@ -1,5 +1,7 @@ package io.quarkus.micrometer.runtime.binder; +import java.util.Objects; + import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.binder.http.Outcome; @@ -46,22 +48,18 @@ public static Tag outcome(int statusCode) { /** * Creates a {@code uri} tag based on the URI of the given {@code request}. - * Falling back to {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND} - * for 404 responses, {@code root} for requests with no path info, and {@code UNKNOWN} + * Falling back to {@code REDIRECTION} for 3xx responses if there wasn't a matched path pattern, {@code NOT_FOUND} + * for 404 responses if there wasn't a matched path pattern, {@code root} for requests with no path info, and + * {@code UNKNOWN} * for all other requests. * * @param pathInfo request path + * @param initialPath initial path before request pattern matching took place. Pass in null if there is pattern matching + * done in the caller. * @param code status code of the response * @return the uri tag derived from the request */ - public static Tag uri(String pathInfo, int code) { - if (code > 0) { - if (code / 100 == 3) { - return URI_REDIRECTION; - } else if (code == 404) { - return URI_NOT_FOUND; - } - } + public static Tag uri(String pathInfo, String initialPath, int code) { if (pathInfo == null) { return URI_UNKNOWN; } @@ -69,7 +67,28 @@ public static Tag uri(String pathInfo, int code) { return URI_ROOT; } + if (code > 0) { + if (code / 100 == 3) { + if (isTemplatedPath(pathInfo, initialPath)) { + return Tag.of("uri", pathInfo); + } else { + return URI_REDIRECTION; + } + } else if (code == 404) { + if (isTemplatedPath(pathInfo, initialPath)) { + return Tag.of("uri", pathInfo); + } else { + return URI_NOT_FOUND; + } + } + } + // Use first segment of request path return Tag.of("uri", pathInfo); } + + private static boolean isTemplatedPath(String pathInfo, String initialPath) { + // only include the path info if it has been matched to a template (initialPath != pathInfo) to avoid a metrics explosion with lots of entries + return initialPath != null && !Objects.equals(initialPath, pathInfo); + } } diff --git a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/RestClientMetricsFilter.java b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/RestClientMetricsFilter.java index 004aa63e2d162..f1c6acb745eed 100644 --- a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/RestClientMetricsFilter.java +++ b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/RestClientMetricsFilter.java @@ -72,7 +72,7 @@ public void filter(final ClientRequestContext requestContext, final ClientRespon Timer.Builder builder = Timer.builder(httpMetricsConfig.getHttpClientRequestsName()) .tags(Tags.of( HttpCommonTags.method(requestContext.getMethod()), - HttpCommonTags.uri(requestPath, statusCode), + HttpCommonTags.uri(requestPath, requestContext.getUri().getPath(), statusCode), HttpCommonTags.outcome(statusCode), HttpCommonTags.status(statusCode), clientName(requestContext))); diff --git a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpClientMetrics.java b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpClientMetrics.java index 8e3fc3474b8e1..5d346eee3428a 100644 --- a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpClientMetrics.java +++ b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpClientMetrics.java @@ -183,7 +183,7 @@ public static class RequestTracker extends RequestMetricInfo { this.tags = origin.and( Tag.of("address", address), HttpCommonTags.method(method), - HttpCommonTags.uri(path, -1)); + HttpCommonTags.uri(path, null, -1)); } void requestReset() { diff --git a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpServerMetrics.java b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpServerMetrics.java index 6f22060edc250..23b605d546109 100644 --- a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpServerMetrics.java +++ b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpServerMetrics.java @@ -99,7 +99,7 @@ public HttpRequestMetric responsePushed(LongTaskTimer.Sample socketMetric, HttpM config.getServerIgnorePatterns()); if (path != null) { registry.counter(nameHttpServerPush, Tags.of( - HttpCommonTags.uri(path, response.statusCode()), + HttpCommonTags.uri(path, requestMetric.initialPath, response.statusCode()), VertxMetricsTags.method(method), VertxMetricsTags.outcome(response), HttpCommonTags.status(response.statusCode()))) @@ -153,7 +153,7 @@ public void requestReset(HttpRequestMetric requestMetric) { Timer.Builder builder = Timer.builder(nameHttpServerRequests) .tags(Tags.of( VertxMetricsTags.method(requestMetric.request().method()), - HttpCommonTags.uri(path, 0), + HttpCommonTags.uri(path, requestMetric.initialPath, 0), Outcome.CLIENT_ERROR.asTag(), HttpCommonTags.STATUS_RESET)); @@ -180,7 +180,7 @@ public void responseEnd(HttpRequestMetric requestMetric, HttpResponse response, Timer.Sample sample = requestMetric.getSample(); Tags allTags = Tags.of( VertxMetricsTags.method(requestMetric.request().method()), - HttpCommonTags.uri(path, response.statusCode()), + HttpCommonTags.uri(path, requestMetric.initialPath, response.statusCode()), VertxMetricsTags.outcome(response), HttpCommonTags.status(response.statusCode())); if (!httpServerMetricsTagsContributors.isEmpty()) { @@ -217,7 +217,7 @@ public LongTaskTimer.Sample connected(LongTaskTimer.Sample sample, HttpRequestMe config.getServerIgnorePatterns()); if (path != null) { return LongTaskTimer.builder(nameWebsocketConnections) - .tags(Tags.of(HttpCommonTags.uri(path, 0))) + .tags(Tags.of(HttpCommonTags.uri(path, requestMetric.initialPath, 0))) .register(registry) .start(); } diff --git a/extensions/micrometer/runtime/src/test/java/io/quarkus/micrometer/runtime/binder/HttpCommonTagsTest.java b/extensions/micrometer/runtime/src/test/java/io/quarkus/micrometer/runtime/binder/HttpCommonTagsTest.java index 2474a9f228c6e..e6bf10ee83dbc 100644 --- a/extensions/micrometer/runtime/src/test/java/io/quarkus/micrometer/runtime/binder/HttpCommonTagsTest.java +++ b/extensions/micrometer/runtime/src/test/java/io/quarkus/micrometer/runtime/binder/HttpCommonTagsTest.java @@ -21,17 +21,21 @@ public void testStatus() { @Test public void testUriRedirect() { - Assertions.assertEquals(HttpCommonTags.URI_REDIRECTION, HttpCommonTags.uri("/moved", 301)); - Assertions.assertEquals(HttpCommonTags.URI_REDIRECTION, HttpCommonTags.uri("/moved", 302)); - Assertions.assertEquals(HttpCommonTags.URI_REDIRECTION, HttpCommonTags.uri("/moved", 304)); + Assertions.assertEquals(HttpCommonTags.URI_REDIRECTION, HttpCommonTags.uri("/moved", null, 301)); + Assertions.assertEquals(HttpCommonTags.URI_REDIRECTION, HttpCommonTags.uri("/moved", null, 302)); + Assertions.assertEquals(HttpCommonTags.URI_REDIRECTION, HttpCommonTags.uri("/moved", null, 304)); + Assertions.assertEquals(Tag.of("uri", "/moved/{id}"), HttpCommonTags.uri("/moved/{id}", "/moved/111", 304)); + Assertions.assertEquals(HttpCommonTags.URI_ROOT, HttpCommonTags.uri("/", null, 304)); } @Test public void testUriDefaults() { - Assertions.assertEquals(HttpCommonTags.URI_ROOT, HttpCommonTags.uri("/", 200)); - Assertions.assertEquals(Tag.of("uri", "/known/ok"), HttpCommonTags.uri("/known/ok", 200)); - Assertions.assertEquals(HttpCommonTags.URI_NOT_FOUND, HttpCommonTags.uri("/invalid", 404)); - Assertions.assertEquals(Tag.of("uri", "/known/bad/request"), HttpCommonTags.uri("/known/bad/request", 400)); - Assertions.assertEquals(Tag.of("uri", "/known/server/error"), HttpCommonTags.uri("/known/server/error", 500)); + Assertions.assertEquals(HttpCommonTags.URI_ROOT, HttpCommonTags.uri("/", null, 200)); + Assertions.assertEquals(HttpCommonTags.URI_ROOT, HttpCommonTags.uri("/", null, 404)); + Assertions.assertEquals(Tag.of("uri", "/known/ok"), HttpCommonTags.uri("/known/ok", null, 200)); + Assertions.assertEquals(HttpCommonTags.URI_NOT_FOUND, HttpCommonTags.uri("/invalid", null, 404)); + Assertions.assertEquals(Tag.of("uri", "/invalid/{id}"), HttpCommonTags.uri("/invalid/{id}", "/invalid/111", 404)); + Assertions.assertEquals(Tag.of("uri", "/known/bad/request"), HttpCommonTags.uri("/known/bad/request", null, 400)); + Assertions.assertEquals(Tag.of("uri", "/known/server/error"), HttpCommonTags.uri("/known/server/error", null, 500)); } } diff --git a/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonEndpoint.java b/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonEndpoint.java index c8052f728476a..e2330472a2eba 100644 --- a/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonEndpoint.java +++ b/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonEndpoint.java @@ -27,6 +27,20 @@ public JsonObject jsonObject(JsonObject input) { return result; } + @POST + @Path("jsonObjectWrapper") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public JsonObjectWrapper jsonObjectWrapper(JsonObjectWrapper wrapper) { + var payload = wrapper.payload; + JsonObject result = new JsonObject(); + result.put("name", payload.getString("name")); + result.put("age", 50); + result.put("nested", new JsonObject(Collections.singletonMap("foo", "bar"))); + result.put("bools", new JsonArray().add(true)); + return new JsonObjectWrapper(result); + } + @POST @Path("jsonArray") @Produces(MediaType.APPLICATION_JSON) @@ -36,4 +50,22 @@ public JsonArray jsonArray(JsonArray input) { result.add("last"); return result; } + + @POST + @Path("jsonArrayWrapper") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public JsonArrayWrapper jsonArrayWrapper(JsonArrayWrapper wrapper) { + var payload = wrapper.payload; + JsonArray result = payload.copy(); + result.add("last"); + return new JsonArrayWrapper(result); + } + + public record JsonObjectWrapper(JsonObject payload) { + } + + public record JsonArrayWrapper(JsonArray payload) { + + } } diff --git a/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonTest.java b/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonTest.java index a62fe11749f18..9756d7c3144ed 100644 --- a/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonTest.java +++ b/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonTest.java @@ -38,6 +38,21 @@ public void testJsonObject() { .body("bools[0]", Matchers.equalTo(true)); } + @Test + public void testJsonObjectWrapper() { + RestAssured.with() + .body("{\"payload\": {\"name\": \"Bob\"}}") + .contentType("application/json") + .post("/vertx/jsonObjectWrapper") + .then() + .statusCode(200) + .contentType("application/json") + .body("payload.name", Matchers.equalTo("Bob")) + .body("payload.age", Matchers.equalTo(50)) + .body("payload.nested.foo", Matchers.equalTo("bar")) + .body("payload.bools[0]", Matchers.equalTo(true)); + } + @Test public void testJsonArray() { RestAssured.with() @@ -51,4 +66,17 @@ public void testJsonArray() { .body("[1]", Matchers.equalTo("last")); } + @Test + public void testJsonArrayWrapper() { + RestAssured.with() + .body("{\"payload\": [\"first\"]}") + .contentType("application/json") + .post("/vertx/jsonArrayWrapper") + .then() + .statusCode(200) + .contentType("application/json") + .body("payload[0]", Matchers.equalTo("first")) + .body("payload[1]", Matchers.equalTo("last")); + } + } 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 30746797b0c0c..49026dc2197d4 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 @@ -500,8 +500,7 @@ public void setupEndpoints(ApplicationIndexBuildItem applicationIndexBuildItem, initConverters.getMethodParam(0)); })) .setConverterSupplierIndexerExtension(new GeneratedConverterIndexerExtension( - (name) -> new GeneratedClassGizmoAdaptor(generatedClassBuildItemBuildProducer, - applicationClassPredicate.test(name)))) + (name) -> new GeneratedClassGizmoAdaptor(generatedClassBuildItemBuildProducer, true))) .setHasRuntimeConverters(!paramConverterProviders.getParamConverterProviders().isEmpty()) .setClassLevelExceptionMappers( classLevelExceptionMappers.isPresent() ? classLevelExceptionMappers.get().getMappers() diff --git a/extensions/vertx/deployment/pom.xml b/extensions/vertx/deployment/pom.xml index aad294e6680e0..fd96dfafdae57 100644 --- a/extensions/vertx/deployment/pom.xml +++ b/extensions/vertx/deployment/pom.xml @@ -33,6 +33,10 @@ io.quarkus quarkus-mutiny-deployment + + io.quarkus + quarkus-jackson-spi + io.quarkus quarkus-junit5-internal diff --git a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxJsonProcessor.java b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxJsonProcessor.java index cbecc141c135e..2708d6d25c976 100644 --- a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxJsonProcessor.java +++ b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxJsonProcessor.java @@ -6,6 +6,13 @@ import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.builditem.nativeimage.RuntimeReinitializedClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; +import io.quarkus.jackson.spi.JacksonModuleBuildItem; +import io.quarkus.vertx.runtime.jackson.JsonArrayDeserializer; +import io.quarkus.vertx.runtime.jackson.JsonArraySerializer; +import io.quarkus.vertx.runtime.jackson.JsonObjectDeserializer; +import io.quarkus.vertx.runtime.jackson.JsonObjectSerializer; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; import io.vertx.core.spi.JsonFactory; public class VertxJsonProcessor { @@ -24,4 +31,16 @@ void nativeSupport(List reinitializeVertxJson, serviceProviderBuildItemBuildProducer .produce(ServiceProviderBuildItem.allProvidersFromClassPath(JsonFactory.class.getName())); } + + @BuildStep + JacksonModuleBuildItem registerJacksonSerDeser() { + return new JacksonModuleBuildItem.Builder("VertxTypes") + .add(JsonArraySerializer.class.getName(), + JsonArrayDeserializer.class.getName(), + JsonArray.class.getName()) + .add(JsonObjectSerializer.class.getName(), + JsonObjectDeserializer.class.getName(), + JsonObject.class.getName()) + .build(); + } } diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferDeserializer.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferDeserializer.java index 696f7182feeab..1ae4c81345b74 100644 --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferDeserializer.java +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferDeserializer.java @@ -25,7 +25,7 @@ /** * Copied from {@code io.vertx.core.json.jackson.BufferDeserializer} as that class is package private */ -class BufferDeserializer extends JsonDeserializer { +public class BufferDeserializer extends JsonDeserializer { @Override public Buffer deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException { diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferSerializer.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferSerializer.java index 508c59083d911..364fbd71eeb31 100644 --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferSerializer.java +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferSerializer.java @@ -23,7 +23,7 @@ /** * Copied from {@code io.vertx.core.json.jackson.BufferSerializer} as that class is package private */ -class BufferSerializer extends JsonSerializer { +public class BufferSerializer extends JsonSerializer { @Override public void serialize(Buffer value, JsonGenerator jgen, SerializerProvider provider) throws IOException { diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArrayDeserializer.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArrayDeserializer.java new file mode 100644 index 0000000000000..59dc87c0097a2 --- /dev/null +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArrayDeserializer.java @@ -0,0 +1,30 @@ +package io.quarkus.vertx.runtime.jackson; + +import java.util.List; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdDelegatingDeserializer; +import com.fasterxml.jackson.databind.util.Converter; +import com.fasterxml.jackson.databind.util.StdConverter; + +import io.vertx.core.json.JsonArray; + +public class JsonArrayDeserializer extends StdDelegatingDeserializer { + + public JsonArrayDeserializer() { + super(new StdConverter, JsonArray>() { + @Override + public JsonArray convert(List list) { + return new JsonArray(list); + } + }); + } + + @Override + protected StdDelegatingDeserializer withDelegate(Converter converter, + JavaType delegateType, + JsonDeserializer delegateDeserializer) { + return new StdDelegatingDeserializer<>(converter, delegateType, delegateDeserializer); + } +} diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArraySerializer.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArraySerializer.java index dc2053b32530b..1c3575152df1e 100644 --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArraySerializer.java +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArraySerializer.java @@ -22,7 +22,7 @@ /** * Copied from {@code io.vertx.core.json.jackson.JsonArraySerializer} as that class is package private */ -class JsonArraySerializer extends JsonSerializer { +public class JsonArraySerializer extends JsonSerializer { @Override public void serialize(JsonArray value, JsonGenerator jgen, SerializerProvider provider) throws IOException { jgen.writeObject(value.getList()); diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectDeserializer.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectDeserializer.java new file mode 100644 index 0000000000000..8dfba6c2c181b --- /dev/null +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectDeserializer.java @@ -0,0 +1,30 @@ +package io.quarkus.vertx.runtime.jackson; + +import java.util.Map; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdDelegatingDeserializer; +import com.fasterxml.jackson.databind.util.Converter; +import com.fasterxml.jackson.databind.util.StdConverter; + +import io.vertx.core.json.JsonObject; + +public class JsonObjectDeserializer extends StdDelegatingDeserializer { + + public JsonObjectDeserializer() { + super(new StdConverter, JsonObject>() { + @Override + public JsonObject convert(Map map) { + return new JsonObject(map); + } + }); + } + + @Override + protected StdDelegatingDeserializer withDelegate(Converter converter, + JavaType delegateType, + JsonDeserializer delegateDeserializer) { + return new StdDelegatingDeserializer<>(converter, delegateType, delegateDeserializer); + } +} diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectSerializer.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectSerializer.java index 07bfed2083ede..e276b09bc36b1 100644 --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectSerializer.java +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectSerializer.java @@ -21,7 +21,7 @@ /** * Copied from {@code io.vertx.core.json.jackson.JsonObjectSerializer} as that class is package private */ -class JsonObjectSerializer extends JsonSerializer { +public class JsonObjectSerializer extends JsonSerializer { @Override public void serialize(JsonObject value, JsonGenerator jgen, SerializerProvider provider) throws IOException { jgen.writeObject(value.getMap()); diff --git a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/CallbackArgument.java b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/CallbackArgument.java new file mode 100644 index 0000000000000..4981903e4b097 --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/CallbackArgument.java @@ -0,0 +1,114 @@ +package io.quarkus.websockets.next.deployment; + +import java.util.Set; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.MethodParameterInfo; +import org.jboss.jandex.Type; + +import io.quarkus.gizmo.BytecodeCreator; +import io.quarkus.gizmo.ResultHandle; +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.WebSocketConnection; +import io.quarkus.websockets.next.WebSocketServerException; + +/** + * Provides arguments for method parameters of a callback method declared on a WebSocket endpoint. + */ +interface CallbackArgument { + + /** + * + * @param context + * @return {@code true} if this provider matches the given parameter context, {@code false} otherwise + * @throws WebSocketServerException If an invalid parameter is detected + */ + boolean matches(ParameterContext context); + + /** + * This method is only used if {@link #matches(ParameterContext)} previously returned {@code true} for the same parameter + * context. + * + * @param context + * @return the result handle to be passed as an argument to a callback method + */ + ResultHandle get(InvocationBytecodeContext context); + + /** + * + * @return the priority + */ + default int priotity() { + return DEFAULT_PRIORITY; + } + + static final int DEFAULT_PRIORITY = 1; + + interface ParameterContext { + + /** + * + * @return the endpoint path + */ + String endpointPath(); + + /** + * + * @return the callback marker annotation + */ + AnnotationInstance callbackAnnotation(); + + /** + * + * @return the Java method parameter + */ + MethodParameterInfo parameter(); + + /** + * + * @return the set of parameter annotations, potentially transformed + */ + Set parameterAnnotations(); + + default boolean acceptsMessage() { + return WebSocketDotNames.ON_BINARY_MESSAGE.equals(callbackAnnotation().name()) + || WebSocketDotNames.ON_TEXT_MESSAGE.equals(callbackAnnotation().name()) + || WebSocketDotNames.ON_PONG_MESSAGE.equals(callbackAnnotation().name()); + } + + } + + interface InvocationBytecodeContext extends ParameterContext { + + /** + * + * @return the bytecode + */ + BytecodeCreator bytecode(); + + /** + * Obtains the message directly in the bytecode. + * + * @return the message object or {@code null} for {@link OnOpen} and {@link OnClose} callbacks + */ + ResultHandle getMessage(); + + /** + * Attempts to obtain the decoded message directly in the bytecode. + * + * @param parameterType + * @return the decoded message object or {@code null} for {@link OnOpen} and {@link OnClose} callbacks + */ + ResultHandle getDecodedMessage(Type parameterType); + + /** + * Obtains the current connection directly in the bytecode. + * + * @return the current {@link WebSocketConnection}, never {@code null} + */ + ResultHandle getConnection(); + + } + +} diff --git a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/CallbackArgumentBuildItem.java b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/CallbackArgumentBuildItem.java new file mode 100644 index 0000000000000..6ae75119a9da4 --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/CallbackArgumentBuildItem.java @@ -0,0 +1,17 @@ +package io.quarkus.websockets.next.deployment; + +import io.quarkus.builder.item.MultiBuildItem; + +final class CallbackArgumentBuildItem extends MultiBuildItem { + + private final CallbackArgument provider; + + CallbackArgumentBuildItem(CallbackArgument provider) { + this.provider = provider; + } + + CallbackArgument getProvider() { + return provider; + } + +} diff --git a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/CallbackArgumentsBuildItem.java b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/CallbackArgumentsBuildItem.java new file mode 100644 index 0000000000000..8c0c9498d88c7 --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/CallbackArgumentsBuildItem.java @@ -0,0 +1,32 @@ +package io.quarkus.websockets.next.deployment; + +import java.util.ArrayList; +import java.util.List; + +import io.quarkus.builder.item.SimpleBuildItem; +import io.quarkus.websockets.next.deployment.CallbackArgument.ParameterContext; + +final class CallbackArgumentsBuildItem extends SimpleBuildItem { + + final List sortedArguments; + + CallbackArgumentsBuildItem(List providers) { + this.sortedArguments = providers; + } + + /** + * + * @param context + * @return all matching providers, never {@code null} + */ + List findMatching(ParameterContext context) { + List matching = new ArrayList<>(); + for (CallbackArgument argument : sortedArguments) { + if (argument.matches(context)) { + matching.add(argument); + } + } + return matching; + } + +} diff --git a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/ConnectionCallbackArgument.java b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/ConnectionCallbackArgument.java new file mode 100644 index 0000000000000..d6d16fc468430 --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/ConnectionCallbackArgument.java @@ -0,0 +1,17 @@ +package io.quarkus.websockets.next.deployment; + +import io.quarkus.gizmo.ResultHandle; + +class ConnectionCallbackArgument implements CallbackArgument { + + @Override + public boolean matches(ParameterContext context) { + return context.parameter().type().name().equals(WebSocketDotNames.WEB_SOCKET_CONNECTION); + } + + @Override + public ResultHandle get(InvocationBytecodeContext context) { + return context.getConnection(); + } + +} diff --git a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/HandshakeRequestCallbackArgument.java b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/HandshakeRequestCallbackArgument.java new file mode 100644 index 0000000000000..0e252ae9e26da --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/HandshakeRequestCallbackArgument.java @@ -0,0 +1,21 @@ +package io.quarkus.websockets.next.deployment; + +import io.quarkus.gizmo.MethodDescriptor; +import io.quarkus.gizmo.ResultHandle; +import io.quarkus.websockets.next.WebSocketConnection; + +class HandshakeRequestCallbackArgument implements CallbackArgument { + + @Override + public boolean matches(ParameterContext context) { + return context.parameter().type().name().equals(WebSocketDotNames.HANDSHAKE_REQUEST); + } + + @Override + public ResultHandle get(InvocationBytecodeContext context) { + ResultHandle connection = context.getConnection(); + return context.bytecode().invokeInterfaceMethod(MethodDescriptor.ofMethod(WebSocketConnection.class, "handshakeRequest", + WebSocketConnection.HandshakeRequest.class), connection); + } + +} diff --git a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/MessageCallbackArgument.java b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/MessageCallbackArgument.java new file mode 100644 index 0000000000000..45e92e857cf39 --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/MessageCallbackArgument.java @@ -0,0 +1,22 @@ +package io.quarkus.websockets.next.deployment; + +import io.quarkus.gizmo.ResultHandle; + +class MessageCallbackArgument implements CallbackArgument { + + @Override + public boolean matches(ParameterContext context) { + return context.acceptsMessage() && context.parameterAnnotations().isEmpty(); + } + + @Override + public ResultHandle get(InvocationBytecodeContext context) { + return context.getDecodedMessage(context.parameter().type()); + } + + @Override + public int priotity() { + return DEFAULT_PRIORITY - 1; + } + +} diff --git a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/PathParamCallbackArgument.java b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/PathParamCallbackArgument.java new file mode 100644 index 0000000000000..c821aebd48088 --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/PathParamCallbackArgument.java @@ -0,0 +1,78 @@ +package io.quarkus.websockets.next.deployment; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationValue; + +import io.quarkus.arc.processor.Annotations; +import io.quarkus.gizmo.MethodDescriptor; +import io.quarkus.gizmo.ResultHandle; +import io.quarkus.websockets.next.WebSocketConnection; +import io.quarkus.websockets.next.WebSocketServerException; + +class PathParamCallbackArgument implements CallbackArgument { + + @Override + public boolean matches(ParameterContext context) { + String paramName = getParamName(context); + if (paramName != null) { + if (!context.parameter().type().name().equals(WebSocketDotNames.STRING)) { + throw new WebSocketServerException("Method parameter annotated with @PathParam must be java.lang.String: " + + WebSocketServerProcessor.callbackToString(context.parameter().method())); + } + List pathParams = getPathParamNames(context.endpointPath()); + if (!pathParams.contains(paramName)) { + throw new WebSocketServerException( + String.format( + "@PathParam name [%s] must be used in the endpoint path [%s]: %s", paramName, + context.endpointPath(), + WebSocketServerProcessor.callbackToString(context.parameter().method()))); + } + return true; + } + return false; + } + + @Override + public ResultHandle get(InvocationBytecodeContext context) { + ResultHandle connection = context.getConnection(); + String paramName = getParamName(context); + return context.bytecode().invokeInterfaceMethod( + MethodDescriptor.ofMethod(WebSocketConnection.class, "pathParam", String.class, String.class), connection, + context.bytecode().load(paramName)); + } + + private String getParamName(ParameterContext context) { + AnnotationInstance pathParamAnnotation = Annotations.find(context.parameterAnnotations(), WebSocketDotNames.PATH_PARAM); + if (pathParamAnnotation != null) { + String paramName; + AnnotationValue nameVal = pathParamAnnotation.value(); + if (nameVal != null) { + paramName = nameVal.asString(); + } else { + // Try to use the element name + paramName = context.parameter().name(); + } + if (paramName == null) { + throw new WebSocketServerException(String.format( + "Unable to extract the path parameter name - method parameter names not recorded for %s: compile the class with -parameters", + context.parameter().method().declaringClass().name())); + } + return paramName; + } + return null; + } + + static List getPathParamNames(String path) { + List names = new ArrayList<>(); + Matcher m = WebSocketServerProcessor.TRANSLATED_PATH_PARAM_PATTERN.matcher(path); + while (m.find()) { + names.add(m.group().substring(1)); + } + return names; + } + +} diff --git a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketDotNames.java b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketDotNames.java index 6bfe88c3ceca1..c6803b6d7baf1 100644 --- a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketDotNames.java +++ b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketDotNames.java @@ -7,6 +7,7 @@ import io.quarkus.websockets.next.OnOpen; import io.quarkus.websockets.next.OnPongMessage; import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.PathParam; import io.quarkus.websockets.next.WebSocket; import io.quarkus.websockets.next.WebSocketConnection; import io.smallrye.common.annotation.Blocking; @@ -35,4 +36,6 @@ final class WebSocketDotNames { static final DotName JSON_OBJECT = DotName.createSimple(JsonObject.class); static final DotName JSON_ARRAY = DotName.createSimple(JsonArray.class); static final DotName VOID = DotName.createSimple(Void.class); + static final DotName PATH_PARAM = DotName.createSimple(PathParam.class); + static final DotName HANDSHAKE_REQUEST = DotName.createSimple(WebSocketConnection.HandshakeRequest.class); } diff --git a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketEndpointBuildItem.java b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketEndpointBuildItem.java index 58f7920d933b8..90064c89aa95f 100644 --- a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketEndpointBuildItem.java +++ b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketEndpointBuildItem.java @@ -1,16 +1,32 @@ package io.quarkus.websockets.next.deployment; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationValue; import org.jboss.jandex.DotName; import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.MethodParameterInfo; import org.jboss.jandex.Type; import org.jboss.jandex.Type.Kind; +import io.quarkus.arc.deployment.TransformedAnnotationsBuildItem; +import io.quarkus.arc.processor.Annotations; import io.quarkus.arc.processor.BeanInfo; +import io.quarkus.arc.processor.DotNames; import io.quarkus.builder.item.MultiBuildItem; +import io.quarkus.gizmo.BytecodeCreator; +import io.quarkus.gizmo.FieldDescriptor; +import io.quarkus.gizmo.ResultHandle; import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; +import io.quarkus.websockets.next.WebSocketServerException; +import io.quarkus.websockets.next.deployment.CallbackArgument.InvocationBytecodeContext; +import io.quarkus.websockets.next.deployment.CallbackArgument.ParameterContext; import io.quarkus.websockets.next.runtime.WebSocketEndpoint.ExecutionModel; +import io.quarkus.websockets.next.runtime.WebSocketEndpointBase; /** * This build item represents a WebSocket endpoint class. @@ -44,8 +60,11 @@ public static class Callback { public final MethodInfo method; public final ExecutionModel executionModel; public final MessageType messageType; + public final List arguments; - public Callback(AnnotationInstance annotation, MethodInfo method, ExecutionModel executionModel) { + public Callback(AnnotationInstance annotation, MethodInfo method, ExecutionModel executionModel, + CallbackArgumentsBuildItem callbackArguments, TransformedAnnotationsBuildItem transformedAnnotations, + String endpointPath) { this.method = method; this.annotation = annotation; this.executionModel = executionModel; @@ -58,6 +77,15 @@ public Callback(AnnotationInstance annotation, MethodInfo method, ExecutionModel } else { this.messageType = MessageType.NONE; } + this.arguments = collectArguments(annotation, method, callbackArguments, transformedAnnotations, endpointPath); + } + + public boolean isOnOpen() { + return annotation.name().equals(WebSocketDotNames.ON_OPEN); + } + + public boolean isOnClose() { + return annotation.name().equals(WebSocketDotNames.ON_CLOSE); } public Type returnType() { @@ -118,13 +146,154 @@ private DotName getCodec(String valueName) { return null; } - enum MessageType { + public enum MessageType { NONE, PONG, TEXT, BINARY } + public List messageArguments() { + if (arguments.isEmpty()) { + return List.of(); + } + List ret = new ArrayList<>(); + for (CallbackArgument arg : arguments) { + if (arg instanceof MessageCallbackArgument) { + ret.add(arg); + } + } + return ret; + } + + public ResultHandle[] generateArguments(BytecodeCreator bytecode, + TransformedAnnotationsBuildItem transformedAnnotations, String endpointPath) { + if (arguments.isEmpty()) { + return new ResultHandle[] {}; + } + ResultHandle[] resultHandles = new ResultHandle[arguments.size()]; + int idx = 0; + for (CallbackArgument argument : arguments) { + resultHandles[idx] = argument.get( + invocationBytecodeContext(annotation, method.parameters().get(idx), transformedAnnotations, + endpointPath, bytecode)); + idx++; + } + return resultHandles; + } + + static List collectArguments(AnnotationInstance annotation, MethodInfo method, + CallbackArgumentsBuildItem callbackArguments, TransformedAnnotationsBuildItem transformedAnnotations, + String endpointPath) { + List parameters = method.parameters(); + if (parameters.isEmpty()) { + return List.of(); + } + List arguments = new ArrayList<>(parameters.size()); + for (MethodParameterInfo parameter : parameters) { + List found = callbackArguments + .findMatching(parameterContext(annotation, parameter, transformedAnnotations, endpointPath)); + if (found.isEmpty()) { + String msg = String.format("Unable to inject @%s callback parameter '%s' declared on %s: no injector found", + DotNames.simpleName(annotation.name()), + parameter.name() != null ? parameter.name() : "#" + parameter.position(), + WebSocketServerProcessor.callbackToString(method)); + throw new WebSocketServerException(msg); + } else if (found.size() > 1 && (found.get(0).priotity() == found.get(1).priotity())) { + String msg = String.format( + "Unable to inject @%s callback parameter '%s' declared on %s: ambiguous injectors found: %s", + DotNames.simpleName(annotation.name()), + parameter.name() != null ? parameter.name() : "#" + parameter.position(), + WebSocketServerProcessor.callbackToString(method), + found.stream().map(p -> p.getClass().getSimpleName() + ":" + p.priotity())); + throw new WebSocketServerException(msg); + } + arguments.add(found.get(0)); + } + return arguments; + } + + static ParameterContext parameterContext(AnnotationInstance callbackAnnotation, MethodParameterInfo parameter, + TransformedAnnotationsBuildItem transformedAnnotations, String endpointPath) { + return new ParameterContext() { + + @Override + public MethodParameterInfo parameter() { + return parameter; + } + + @Override + public Set parameterAnnotations() { + return Annotations.getParameterAnnotations( + transformedAnnotations::getAnnotations, parameter.method(), parameter.position()); + } + + @Override + public AnnotationInstance callbackAnnotation() { + return callbackAnnotation; + } + + @Override + public String endpointPath() { + return endpointPath; + } + + }; + } + + private InvocationBytecodeContext invocationBytecodeContext(AnnotationInstance callbackAnnotation, + MethodParameterInfo parameter, TransformedAnnotationsBuildItem transformedAnnotations, String endpointPath, + BytecodeCreator bytecode) { + return new InvocationBytecodeContext() { + + @Override + public AnnotationInstance callbackAnnotation() { + return callbackAnnotation; + } + + @Override + public MethodParameterInfo parameter() { + return parameter; + } + + @Override + public Set parameterAnnotations() { + return Annotations.getParameterAnnotations( + transformedAnnotations::getAnnotations, parameter.method(), parameter.position()); + } + + @Override + public String endpointPath() { + return endpointPath; + } + + @Override + public BytecodeCreator bytecode() { + return bytecode; + } + + @Override + public ResultHandle getMessage() { + return acceptsMessage() ? bytecode.getMethodParam(0) : null; + } + + @Override + public ResultHandle getDecodedMessage(Type parameterType) { + return acceptsMessage() + ? WebSocketServerProcessor.decodeMessage(bytecode, acceptsBinaryMessage(), parameterType, + getMessage(), Callback.this) + : null; + } + + @Override + public ResultHandle getConnection() { + return bytecode.readInstanceField( + FieldDescriptor.of(WebSocketEndpointBase.class, "connection", WebSocketConnection.class), + bytecode.getThis()); + } + }; + } + } } diff --git a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketServerProcessor.java b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketServerProcessor.java index fecdd74c0606a..9cb8d7b27d076 100644 --- a/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketServerProcessor.java +++ b/extensions/websockets-next/server/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketServerProcessor.java @@ -3,6 +3,7 @@ import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -32,6 +33,7 @@ import io.quarkus.arc.deployment.ContextRegistrationPhaseBuildItem.ContextConfiguratorBuildItem; import io.quarkus.arc.deployment.CustomScopeBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.deployment.TransformedAnnotationsBuildItem; import io.quarkus.arc.deployment.UnremovableBeanBuildItem; import io.quarkus.arc.processor.BeanInfo; import io.quarkus.arc.processor.DotNames; @@ -83,6 +85,7 @@ public class WebSocketServerProcessor { static final String NESTED_SEPARATOR = "$_"; private static final Pattern PATH_PARAM_PATTERN = Pattern.compile("\\{[a-zA-Z0-9_]+\\}"); + public static final Pattern TRANSLATED_PATH_PARAM_PATTERN = Pattern.compile(":[a-zA-Z0-9_]+"); @BuildStep FeatureBuildItem feature() { @@ -102,6 +105,8 @@ void unremovableBeans(BuildProducer unremovableBeans) @BuildStep public void collectEndpoints(BeanArchiveIndexBuildItem beanArchiveIndex, BeanDiscoveryFinishedBuildItem beanDiscoveryFinished, + CallbackArgumentsBuildItem argumentProviders, + TransformedAnnotationsBuildItem transformedAnnotations, BuildProducer endpoints) { IndexView index = beanArchiveIndex.getIndex(); @@ -122,15 +127,16 @@ public void collectEndpoints(BeanArchiveIndexBuildItem beanArchiveIndex, String.format("Multiple endpoints [%s, %s] define the same path: %s", previous, beanClass, path)); } Callback onOpen = findCallback(beanArchiveIndex.getIndex(), beanClass, WebSocketDotNames.ON_OPEN, - this::validateOnOpen); + argumentProviders, transformedAnnotations, path); Callback onTextMessage = findCallback(beanArchiveIndex.getIndex(), beanClass, WebSocketDotNames.ON_TEXT_MESSAGE, - this::validateOnTextMessage); + argumentProviders, transformedAnnotations, path); Callback onBinaryMessage = findCallback(beanArchiveIndex.getIndex(), beanClass, - WebSocketDotNames.ON_BINARY_MESSAGE, - this::validateOnBinaryMessage); + WebSocketDotNames.ON_BINARY_MESSAGE, argumentProviders, transformedAnnotations, path); Callback onPongMessage = findCallback(beanArchiveIndex.getIndex(), beanClass, WebSocketDotNames.ON_PONG_MESSAGE, + argumentProviders, transformedAnnotations, path, this::validateOnPongMessage); Callback onClose = findCallback(beanArchiveIndex.getIndex(), beanClass, WebSocketDotNames.ON_CLOSE, + argumentProviders, transformedAnnotations, path, this::validateOnClose); if (onOpen == null && onTextMessage == null && onBinaryMessage == null && onPongMessage == null) { throw new WebSocketServerException( @@ -150,8 +156,20 @@ public void collectEndpoints(BeanArchiveIndexBuildItem beanArchiveIndex, } } + @BuildStep + CallbackArgumentsBuildItem collectCallbackArguments(List callbackArguments) { + List sorted = new ArrayList<>(); + for (CallbackArgumentBuildItem callbackArgument : callbackArguments) { + sorted.add(callbackArgument.getProvider()); + } + sorted.sort(Comparator.comparingInt(CallbackArgument::priotity).reversed()); + return new CallbackArgumentsBuildItem(sorted); + } + @BuildStep public void generateEndpoints(List endpoints, + CallbackArgumentsBuildItem argumentProviders, + TransformedAnnotationsBuildItem transformedAnnotations, BuildProducer generatedClasses, BuildProducer generatedEndpoints, BuildProducer reflectiveClasses) { @@ -173,7 +191,7 @@ public String apply(String name) { // A new instance of this generated endpoint is created for each client connection // The generated endpoint ensures the correct execution model is used // and delegates callback invocations to the endpoint bean - String generatedName = generateEndpoint(endpoint, classOutput); + String generatedName = generateEndpoint(endpoint, argumentProviders, transformedAnnotations, classOutput); reflectiveClasses.produce(ReflectiveClassBuildItem.builder(generatedName).constructors().build()); generatedEndpoints.produce(new GeneratedEndpointBuildItem(generatedName, endpoint.path)); } @@ -224,6 +242,14 @@ CustomScopeBuildItem registerSessionScope() { return new CustomScopeBuildItem(DotName.createSimple(SessionScoped.class.getName())); } + @BuildStep + void builtinCallbackArguments(BuildProducer providers) { + providers.produce(new CallbackArgumentBuildItem(new MessageCallbackArgument())); + providers.produce(new CallbackArgumentBuildItem(new ConnectionCallbackArgument())); + providers.produce(new CallbackArgumentBuildItem(new PathParamCallbackArgument())); + providers.produce(new CallbackArgumentBuildItem(new HandshakeRequestCallbackArgument())); + } + static String mergePath(String prefix, String path) { if (prefix.endsWith("/")) { prefix = prefix.substring(0, prefix.length() - 1); @@ -246,7 +272,7 @@ static String getPath(String path) { return sb.toString(); } - private String callbackToString(MethodInfo callback) { + static String callbackToString(MethodInfo callback) { return callback.declaringClass().name() + "#" + callback.name() + "()"; } @@ -267,47 +293,24 @@ private String getPathPrefix(IndexView index, DotName enclosingClassName) { return ""; } - private void validateOnOpen(MethodInfo callback) { - if (!callback.parameters().isEmpty()) { - throw new WebSocketServerException( - "@OnOpen callback must not accept any parameters: " + callbackToString(callback)); - } - } - - private void validateOnTextMessage(MethodInfo callback) { - if (callback.parameters().size() != 1) { - throw new WebSocketServerException( - "@OnTextMessage callback must accept exactly one parameter: " + callbackToString(callback)); - } - } - - private void validateOnBinaryMessage(MethodInfo callback) { - if (callback.parameters().size() != 1) { - throw new WebSocketServerException( - "@OnTextMessage callback must accept exactly one parameter: " + callbackToString(callback)); - } - } - - private void validateOnPongMessage(MethodInfo callback) { + private void validateOnPongMessage(Callback callback) { if (callback.returnType().kind() != Kind.VOID && !WebSocketServerProcessor.isUniVoid(callback.returnType())) { throw new WebSocketServerException( - "@OnPongMessage callback must return void or Uni: " + callbackToString(callback)); - } - if (callback.parameters().size() != 1 || !callback.parameterType(0).name().equals(WebSocketDotNames.BUFFER)) { - throw new WebSocketServerException( - "@OnPongMessage callback must accept exactly one parameter of type io.vertx.core.buffer.Buffer: " - + callbackToString(callback)); + "@OnPongMessage callback must return void or Uni: " + callbackToString(callback.method)); } + // TODO validate message arguments + // List> messageArguments = getMessageArguments(providers); + // if (messageArguments.size() != 1 || !messageArguments.get(0).getKey().type().name().equals(WebSocketDotNames.BUFFER)) { + // throw new WebSocketServerException( + // "@OnPongMessage callback must accept exactly one message parameter of type io.vertx.core.buffer.Buffer: " + // + callbackToString(callback.method)); + // } } - private void validateOnClose(MethodInfo callback) { + private void validateOnClose(Callback callback) { if (callback.returnType().kind() != Kind.VOID && !WebSocketServerProcessor.isUniVoid(callback.returnType())) { throw new WebSocketServerException( - "@OnClose callback must return void or Uni: " + callbackToString(callback)); - } - if (!callback.parameters().isEmpty()) { - throw new WebSocketServerException( - "@OnClose callback must not accept any parameters: " + callbackToString(callback)); + "@OnClose callback must return void or Uni: " + callbackToString(callback.method)); } } @@ -346,7 +349,10 @@ private void validateOnClose(MethodInfo callback) { * @param classOutput * @return the name of the generated class */ - private String generateEndpoint(WebSocketEndpointBuildItem endpoint, ClassOutput classOutput) { + private String generateEndpoint(WebSocketEndpointBuildItem endpoint, + CallbackArgumentsBuildItem argumentProviders, + TransformedAnnotationsBuildItem transformedAnnotations, + ClassOutput classOutput) { ClassInfo implClazz = endpoint.bean.getImplClazz(); String baseName; if (implClazz.enclosingClass() != null) { @@ -375,6 +381,7 @@ private String generateEndpoint(WebSocketEndpointBuildItem endpoint, ClassOutput executionMode.returnValue(executionMode.load(endpoint.executionMode)); if (endpoint.onOpen != null) { + Callback callback = endpoint.onOpen; MethodCreator doOnOpen = endpointCreator.getMethodCreator("doOnOpen", Uni.class, Object.class); // Foo foo = beanInstance("foo"); ResultHandle beanInstance = doOnOpen.invokeSpecialMethod( @@ -382,19 +389,21 @@ private String generateEndpoint(WebSocketEndpointBuildItem endpoint, ClassOutput doOnOpen.getThis(), doOnOpen.load(endpoint.bean.getIdentifier())); // Call the business method TryBlock tryBlock = uniFailureTryBlock(doOnOpen); - ResultHandle ret = tryBlock.invokeVirtualMethod(MethodDescriptor.of(endpoint.onOpen.method), beanInstance); - encodeAndReturnResult(tryBlock, endpoint.onOpen, ret); + ResultHandle[] args = callback.generateArguments(tryBlock, transformedAnnotations, endpoint.path); + ResultHandle ret = tryBlock.invokeVirtualMethod(MethodDescriptor.of(callback.method), beanInstance, args); + encodeAndReturnResult(tryBlock, callback, ret); MethodCreator onOpenExecutionModel = endpointCreator.getMethodCreator("onOpenExecutionModel", ExecutionModel.class); - onOpenExecutionModel.returnValue(onOpenExecutionModel.load(endpoint.onOpen.executionModel)); + onOpenExecutionModel.returnValue(onOpenExecutionModel.load(callback.executionModel)); } - generateOnMessage(endpointCreator, endpoint, endpoint.onBinaryMessage); - generateOnMessage(endpointCreator, endpoint, endpoint.onTextMessage); - generateOnMessage(endpointCreator, endpoint, endpoint.onPongMessage); + generateOnMessage(endpointCreator, endpoint, endpoint.onBinaryMessage, argumentProviders, transformedAnnotations); + generateOnMessage(endpointCreator, endpoint, endpoint.onTextMessage, argumentProviders, transformedAnnotations); + generateOnMessage(endpointCreator, endpoint, endpoint.onPongMessage, argumentProviders, transformedAnnotations); if (endpoint.onClose != null) { + Callback callback = endpoint.onClose; MethodCreator doOnClose = endpointCreator.getMethodCreator("doOnClose", Uni.class, Object.class); // Foo foo = beanInstance("foo"); ResultHandle beanInstance = doOnClose.invokeSpecialMethod( @@ -402,19 +411,22 @@ private String generateEndpoint(WebSocketEndpointBuildItem endpoint, ClassOutput doOnClose.getThis(), doOnClose.load(endpoint.bean.getIdentifier())); // Call the business method TryBlock tryBlock = uniFailureTryBlock(doOnClose); - ResultHandle ret = tryBlock.invokeVirtualMethod(MethodDescriptor.of(endpoint.onClose.method), beanInstance); - encodeAndReturnResult(tryBlock, endpoint.onClose, ret); + ResultHandle[] args = callback.generateArguments(tryBlock, transformedAnnotations, endpoint.path); + ResultHandle ret = tryBlock.invokeVirtualMethod(MethodDescriptor.of(callback.method), beanInstance, args); + encodeAndReturnResult(tryBlock, callback, ret); MethodCreator onCloseExecutionModel = endpointCreator.getMethodCreator("onCloseExecutionModel", ExecutionModel.class); - onCloseExecutionModel.returnValue(onCloseExecutionModel.load(endpoint.onClose.executionModel)); + onCloseExecutionModel.returnValue(onCloseExecutionModel.load(callback.executionModel)); } endpointCreator.close(); return generatedName.replace('/', '.'); } - private void generateOnMessage(ClassCreator endpointCreator, WebSocketEndpointBuildItem endpoint, Callback callback) { + private void generateOnMessage(ClassCreator endpointCreator, WebSocketEndpointBuildItem endpoint, Callback callback, + CallbackArgumentsBuildItem paramInjectors, + TransformedAnnotationsBuildItem transformedAnnotations) { if (callback == null) { return; } @@ -442,15 +454,9 @@ private void generateOnMessage(ClassCreator endpointCreator, WebSocketEndpointBu ResultHandle beanInstance = doOnMessage.invokeSpecialMethod( MethodDescriptor.ofMethod(WebSocketEndpointBase.class, "beanInstance", Object.class, String.class), doOnMessage.getThis(), doOnMessage.load(endpoint.bean.getIdentifier())); - ResultHandle[] args; - if (callback.acceptsMessage()) { - args = new ResultHandle[] { decodeMessage(doOnMessage, callback.acceptsBinaryMessage(), - callback.method.parameterType(0), doOnMessage.getMethodParam(0), callback) }; - } else { - args = new ResultHandle[] {}; - } // Call the business method TryBlock tryBlock = uniFailureTryBlock(doOnMessage); + ResultHandle[] args = callback.generateArguments(tryBlock, transformedAnnotations, endpoint.path); ResultHandle ret = tryBlock.invokeVirtualMethod(MethodDescriptor.of(callback.method), beanInstance, args); encodeAndReturnResult(tryBlock, callback, ret); @@ -484,7 +490,7 @@ private TryBlock uniFailureTryBlock(BytecodeCreator method) { return tryBlock; } - private ResultHandle decodeMessage(MethodCreator method, boolean binaryMessage, Type valueType, ResultHandle value, + static ResultHandle decodeMessage(BytecodeCreator method, boolean binaryMessage, Type valueType, ResultHandle value, Callback callback) { if (WebSocketDotNames.MULTI.equals(valueType.name())) { // Multi is decoded at runtime in the recorder @@ -743,7 +749,15 @@ private void encodeAndReturnResult(BytecodeCreator method, Callback callback, Re } private Callback findCallback(IndexView index, ClassInfo beanClass, DotName annotationName, - Consumer validator) { + CallbackArgumentsBuildItem callbackArguments, TransformedAnnotationsBuildItem transformedAnnotations, + String endpointPath) { + return findCallback(index, beanClass, annotationName, callbackArguments, transformedAnnotations, endpointPath, null); + } + + private Callback findCallback(IndexView index, ClassInfo beanClass, DotName annotationName, + CallbackArgumentsBuildItem callbackArguments, TransformedAnnotationsBuildItem transformedAnnotations, + String endpointPath, + Consumer validator) { ClassInfo aClass = beanClass; List annotations = new ArrayList<>(); while (aClass != null) { @@ -762,8 +776,30 @@ private Callback findCallback(IndexView index, ClassInfo beanClass, DotName anno } else if (annotations.size() == 1) { AnnotationInstance annotation = annotations.get(0); MethodInfo method = annotation.target().asMethod(); - validator.accept(method); - return new Callback(annotation, method, executionModel(method)); + Callback callback = new Callback(annotation, method, executionModel(method), callbackArguments, + transformedAnnotations, endpointPath); + int messageArguments = callback.messageArguments().size(); + if (callback.acceptsMessage()) { + if (messageArguments > 1) { + throw new WebSocketServerException( + String.format("@%s callback may accept at most 1 message parameter; found %s: %s", + DotNames.simpleName(callback.annotation.name()), + messageArguments, + callbackToString(callback.method))); + } + } else { + if (messageArguments != 0) { + throw new WebSocketServerException( + String.format("@%s callback must not accept a message parameter; found %s: %s", + DotNames.simpleName(callback.annotation.name()), + messageArguments, + callbackToString(callback.method))); + } + } + if (validator != null) { + validator.accept(callback); + } + return callback; } throw new WebSocketServerException( String.format("There can be only one callback annotated with %s declared on %s", annotationName, beanClass)); diff --git a/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/ConnectionArgumentTest.java b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/ConnectionArgumentTest.java new file mode 100644 index 0000000000000..d137c345ac80b --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/ConnectionArgumentTest.java @@ -0,0 +1,66 @@ +package io.quarkus.websockets.next.test.args; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; + +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.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.http.WebSocketConnectOptions; +import io.vertx.core.json.JsonObject; + +public class ConnectionArgumentTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(Echo.class, WSClient.class); + }); + + @Inject + Vertx vertx; + + @TestHTTPResource("echo") + URI testUri; + + @Test + void testArgument() { + String message = "ok"; + String header = "fool"; + WSClient client = WSClient.create(vertx).connect(new WebSocketConnectOptions().addHeader("X-Test", header), + testUri); + JsonObject reply = client.sendAndAwaitReply(message).toJsonObject(); + assertEquals(header, reply.getString("header"), reply.toString()); + assertEquals(message, reply.getString("message"), reply.toString()); + } + + @WebSocket(path = "/echo") + public static class Echo { + + @Inject + WebSocketConnection c; + + @OnTextMessage + Uni process(WebSocketConnection connection, String message) throws InterruptedException { + assertEquals(c.id(), connection.id()); + return connection.sendText( + new JsonObject() + .put("id", connection.id()) + .put("message", message) + .put("header", connection.handshakeRequest().header("X-Test"))); + } + + } + +} diff --git a/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/HandshakeRequestArgumentTest.java b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/HandshakeRequestArgumentTest.java new file mode 100644 index 0000000000000..5bcb9ac19f0ba --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/HandshakeRequestArgumentTest.java @@ -0,0 +1,53 @@ +package io.quarkus.websockets.next.test.args; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; + +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.OnOpen; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection.HandshakeRequest; +import io.quarkus.websockets.next.test.utils.WSClient; +import io.vertx.core.Vertx; +import io.vertx.core.http.WebSocketConnectOptions; + +public class HandshakeRequestArgumentTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(XTest.class, WSClient.class); + }); + + @Inject + Vertx vertx; + + @TestHTTPResource("xtest") + URI testUri; + + @Test + void testArgument() { + WSClient client = WSClient.create(vertx).connect(new WebSocketConnectOptions().addHeader("X-Test", "fool"), + testUri); + client.waitForMessages(1); + assertEquals("fool", client.getLastMessage().toString()); + } + + @WebSocket(path = "/xtest") + public static class XTest { + + @OnOpen + String open(HandshakeRequest request) { + return request.header("X-Test"); + } + + } + +} diff --git a/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/OnCloseInvalidArgumentTest.java b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/OnCloseInvalidArgumentTest.java new file mode 100644 index 0000000000000..c5a934eeb6cdf --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/OnCloseInvalidArgumentTest.java @@ -0,0 +1,38 @@ +package io.quarkus.websockets.next.test.args; + +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketServerException; + +public class OnCloseInvalidArgumentTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(Endpoint.class); + }) + .setExpectedException(WebSocketServerException.class); + + @Test + void testInvalidArgument() { + fail(); + } + + @WebSocket(path = "/end") + public static class Endpoint { + + @OnClose + void close(List unsupported) { + } + + } + +} diff --git a/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/OnOpenInvalidArgumentTest.java b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/OnOpenInvalidArgumentTest.java new file mode 100644 index 0000000000000..5f3b9071cf546 --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/OnOpenInvalidArgumentTest.java @@ -0,0 +1,38 @@ +package io.quarkus.websockets.next.test.args; + +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketServerException; + +public class OnOpenInvalidArgumentTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(Endpoint.class); + }) + .setExpectedException(WebSocketServerException.class); + + @Test + void testInvalidArgument() { + fail(); + } + + @WebSocket(path = "/end") + public static class Endpoint { + + @OnOpen + void open(List unsupported) { + } + + } + +} diff --git a/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamArgumentExplicitNameTest.java b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamArgumentExplicitNameTest.java new file mode 100644 index 0000000000000..aa68c510d3eb4 --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamArgumentExplicitNameTest.java @@ -0,0 +1,50 @@ +package io.quarkus.websockets.next.test.args; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; + +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.OnTextMessage; +import io.quarkus.websockets.next.PathParam; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.test.utils.WSClient; +import io.vertx.core.Vertx; + +public class PathParamArgumentExplicitNameTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(MontyEcho.class, WSClient.class); + }); + + @Inject + Vertx vertx; + + @TestHTTPResource("echo/monty") + URI testUri; + + @Test + void testArgument() { + WSClient client = WSClient.create(vertx).connect(testUri); + assertEquals("python:monty", client.sendAndAwaitReply("python").toString()); + } + + @WebSocket(path = "/echo/{grail}") + public static class MontyEcho { + + @OnTextMessage + String process(@PathParam("grail") String life, String message) throws InterruptedException { + return message + ":" + life; + } + + } + +} diff --git a/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamArgumentInvalidNameTest.java b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamArgumentInvalidNameTest.java new file mode 100644 index 0000000000000..f23f0343cdf23 --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamArgumentInvalidNameTest.java @@ -0,0 +1,37 @@ +package io.quarkus.websockets.next.test.args; + +import static org.junit.jupiter.api.Assertions.fail; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.PathParam; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketServerException; + +public class PathParamArgumentInvalidNameTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(MontyEcho.class); + }).setExpectedException(WebSocketServerException.class); + + @Test + void testInvalidArgument() { + fail(); + } + + @WebSocket(path = "/echo/{grail}") + public static class MontyEcho { + + @OnTextMessage + String process(@PathParam String life, String message) throws InterruptedException { + return message + ":" + life; + } + + } + +} diff --git a/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamArgumentInvalidTypeTest.java b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamArgumentInvalidTypeTest.java new file mode 100644 index 0000000000000..31097c8bf7180 --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamArgumentInvalidTypeTest.java @@ -0,0 +1,37 @@ +package io.quarkus.websockets.next.test.args; + +import static org.junit.jupiter.api.Assertions.fail; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.PathParam; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketServerException; + +public class PathParamArgumentInvalidTypeTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(MontyEcho.class); + }).setExpectedException(WebSocketServerException.class); + + @Test + void testInvalidArgument() { + fail(); + } + + @WebSocket(path = "/echo/{grail}") + public static class MontyEcho { + + @OnTextMessage + String process(@PathParam Double grail, String message) throws InterruptedException { + return message + ":" + grail; + } + + } + +} diff --git a/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamArgumentTest.java b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamArgumentTest.java new file mode 100644 index 0000000000000..ff97071f3042d --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamArgumentTest.java @@ -0,0 +1,50 @@ +package io.quarkus.websockets.next.test.args; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; + +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.OnTextMessage; +import io.quarkus.websockets.next.PathParam; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.test.utils.WSClient; +import io.vertx.core.Vertx; + +public class PathParamArgumentTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(MontyEcho.class, WSClient.class); + }); + + @Inject + Vertx vertx; + + @TestHTTPResource("echo/monty") + URI testUri; + + @Test + void testArgument() { + WSClient client = WSClient.create(vertx).connect(testUri); + assertEquals("python:monty", client.sendAndAwaitReply("python").toString()); + } + + @WebSocket(path = "/echo/{grail}") + public static class MontyEcho { + + @OnTextMessage + String process(@PathParam String grail, String message) throws InterruptedException { + return message + ":" + grail; + } + + } + +} diff --git a/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamConnectionArgumentTest.java b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamConnectionArgumentTest.java new file mode 100644 index 0000000000000..5a5e0843a9e64 --- /dev/null +++ b/extensions/websockets-next/server/deployment/src/test/java/io/quarkus/websockets/next/test/args/PathParamConnectionArgumentTest.java @@ -0,0 +1,54 @@ +package io.quarkus.websockets.next.test.args; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; + +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.OnTextMessage; +import io.quarkus.websockets.next.PathParam; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; +import io.quarkus.websockets.next.test.utils.WSClient; +import io.vertx.core.Vertx; +import io.vertx.core.http.WebSocketConnectOptions; + +public class PathParamConnectionArgumentTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(MontyEcho.class, WSClient.class); + }); + + @Inject + Vertx vertx; + + @TestHTTPResource("echo/monty/and/foo") + URI testUri; + + @Test + void testArguments() { + String header = "fool"; + WSClient client = WSClient.create(vertx).connect(new WebSocketConnectOptions().addHeader("X-Test", header), testUri); + assertEquals("foo:python:monty:fool", client.sendAndAwaitReply("python").toString()); + } + + @WebSocket(path = "/echo/{grail}/and/{life}") + public static class MontyEcho { + + @OnTextMessage + String process(@PathParam String life, @PathParam String grail, String message, WebSocketConnection connection) + throws InterruptedException { + return life + ":" + message + ":" + grail + ":" + connection.handshakeRequest().header("X-Test"); + } + + } + +} diff --git a/extensions/websockets-next/server/runtime/src/main/java/io/quarkus/websockets/next/PathParam.java b/extensions/websockets-next/server/runtime/src/main/java/io/quarkus/websockets/next/PathParam.java new file mode 100644 index 0000000000000..353e965e194bf --- /dev/null +++ b/extensions/websockets-next/server/runtime/src/main/java/io/quarkus/websockets/next/PathParam.java @@ -0,0 +1,34 @@ +package io.quarkus.websockets.next; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Identifies an endpoint callback method parameter that should be injected with a value returned from + * {@link WebSocketConnection#pathParam(String)}. + *

+ * The parameter type must be {@link String} and the name must be defined in the relevant endpoint path, otherwise + * the build fails. + * + * @see WebSocketConnection#pathParam(String) + * @see WebSocket + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface PathParam { + + /** + * Constant value for {@link #value()} indicating that the annotated element's name should be used as-is. + */ + String ELEMENT_NAME = "<>"; + + /** + * The name of the parameter. By default, the element's name is used as-is. + * + * @return the name of the parameter + */ + String value() default ELEMENT_NAME; + +} diff --git a/extensions/websockets-next/server/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionImpl.java b/extensions/websockets-next/server/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionImpl.java index 711ce1e654874..7bf534db20db4 100644 --- a/extensions/websockets-next/server/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionImpl.java +++ b/extensions/websockets-next/server/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionImpl.java @@ -12,11 +12,15 @@ import java.util.function.Predicate; import java.util.stream.Collectors; +import io.quarkus.vertx.core.runtime.VertxBufferImpl; import io.quarkus.websockets.next.WebSocketConnection; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.vertx.UniHelper; import io.vertx.core.buffer.Buffer; +import io.vertx.core.buffer.impl.BufferImpl; import io.vertx.core.http.ServerWebSocket; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; import io.vertx.ext.web.RoutingContext; class WebSocketConnectionImpl implements WebSocketConnection { @@ -71,7 +75,17 @@ public Uni sendBinary(Buffer message) { @Override public Uni sendText(M message) { - return UniHelper.toUni(webSocket.writeTextMessage(codecs.textEncode(message, null).toString())); + String text; + // Use the same conversion rules as defined for the OnTextMessage + if (message instanceof JsonObject || message instanceof JsonArray || message instanceof BufferImpl + || message instanceof VertxBufferImpl) { + text = message.toString(); + } else if (message.getClass().isArray() && message.getClass().arrayType().equals(byte.class)) { + text = Buffer.buffer((byte[]) message).toString(); + } else { + text = codecs.textEncode(message, null); + } + return sendText(text); } @Override diff --git a/extensions/websockets-next/server/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketEndpointBase.java b/extensions/websockets-next/server/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketEndpointBase.java index fdb032ae57ca4..8d9620f09c10a 100644 --- a/extensions/websockets-next/server/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketEndpointBase.java +++ b/extensions/websockets-next/server/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketEndpointBase.java @@ -27,7 +27,8 @@ public abstract class WebSocketEndpointBase implements WebSocketEndpoint { private static final Logger LOG = Logger.getLogger(WebSocketEndpointBase.class); - protected final WebSocketConnection connection; + // Keep this field public - there's a problem with ConnectionArgumentProvider reading the protected field in the test mode + public final WebSocketConnection connection; protected final Codecs codecs; diff --git a/integration-tests/jpa-postgresql/src/test/resources/image-metrics/23.1/image-metrics.properties b/integration-tests/jpa-postgresql/src/test/resources/image-metrics/23.1/image-metrics.properties index 6315f449e1bf9..507eb65104e09 100644 --- a/integration-tests/jpa-postgresql/src/test/resources/image-metrics/23.1/image-metrics.properties +++ b/integration-tests/jpa-postgresql/src/test/resources/image-metrics/23.1/image-metrics.properties @@ -7,7 +7,7 @@ analysis_results.methods.reachable=96465 analysis_results.methods.reachable.tolerance=3 analysis_results.fields.reachable=27025 analysis_results.fields.reachable.tolerance=3 -analysis_results.types.reflection=6048 +analysis_results.types.reflection=6100 analysis_results.types.reflection.tolerance=3 analysis_results.methods.reflection=4707 analysis_results.methods.reflection.tolerance=3