diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 52835b0dd738f..dc2d9d98a9df9 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1239,6 +1239,16 @@ quarkus-infinispan-client-deployment ${project.version} + + io.quarkus + quarkus-infinispan-client-sessions + ${project.version} + + + io.quarkus + quarkus-infinispan-client-sessions-deployment + ${project.version} + io.quarkus quarkus-jaeger @@ -5972,6 +5982,11 @@ quarkus-redis-client-runtime-spi ${project.version} + + io.quarkus + quarkus-redis-client-sessions + ${project.version} + io.quarkus quarkus-redis-cache @@ -5988,6 +6003,11 @@ quarkus-redis-client-deployment-spi ${project.version} + + io.quarkus + quarkus-redis-client-sessions-deployment + ${project.version} + io.quarkus quarkus-redis-cache-deployment diff --git a/devtools/bom-descriptor-json/pom.xml b/devtools/bom-descriptor-json/pom.xml index cbdbc050e4dfc..8dfa44fda4f1c 100644 --- a/devtools/bom-descriptor-json/pom.xml +++ b/devtools/bom-descriptor-json/pom.xml @@ -889,6 +889,19 @@ + + io.quarkus + quarkus-infinispan-client-sessions + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-info @@ -1838,6 +1851,19 @@ + + io.quarkus + quarkus-redis-client-sessions + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-rest-client diff --git a/docs/pom.xml b/docs/pom.xml index dbf502dbfae57..01e3333970ccb 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -905,6 +905,19 @@ + + io.quarkus + quarkus-infinispan-client-sessions-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-info-deployment @@ -1854,6 +1867,19 @@ + + io.quarkus + quarkus-redis-client-sessions-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-rest-client-deployment diff --git a/docs/src/main/asciidoc/http-reference.adoc b/docs/src/main/asciidoc/http-reference.adoc index 6f9ce18e86cbd..4aa81abbeb554 100644 --- a/docs/src/main/asciidoc/http-reference.adoc +++ b/docs/src/main/asciidoc/http-reference.adoc @@ -544,7 +544,132 @@ link:https://undertow.io/undertow-docs/undertow-docs-2.0.0/index.html#predicates If you are using a `web.xml` file as your configuration file, you can place it in the `src/main/resources/META-INF` directory. -=== Built-in route order values +[[vertx-web-sessions]] +== Sessions + +Quarkus includes support for sessions, based on https://vertx.io/docs/vertx-web/java/#_handling_sessions[Vert.x Web sessions]. + +By default, sessions are disabled. +To enable them, set the `quarkus.http.sessions.mode` configuration property to: + +`in-memory`:: for sessions stored in memory of the Quarkus application +`redis`:: for sessions stored in an external Redis server; requires using the Quarkus Redis Client extension +`infinispan`:: for sessions stored in an external Infinispan data grid; requires using the Quarkus Infinispan Client extension + +This configuration property is fixed at build time and cannot be changed at runtime. + +WARNING: Undertow includes its own support for sessions. +If Undertow is present, Vert.x Web sessions cannot be enabled. + +Sessions require using a cookie, which holds the session identifier. +By default, the cookie name is `JSESSIONID`. +Cookieless sessions are not supported. +Storing session data directly into the session cookie is not supported either. + +=== Accessing sessions + +When sessions are enabled, the Vert.x Web `Session` object may be obtained from the current `RoutingContext` using `RoutingContext.session()`. +Alternatively, the `io.vertx.ext.web.Session` object may be injected. + +When using non-clustered in-memory sessions, arbitrary objects may be stored into a session. + +With cluster-wide in-memory sessions, Redis, or Infinispan, the following data types may be stored into a session: + +* primitive wrapper types +** `java.lang.Boolean` +** `java.lang.Byte` +** `java.lang.Short` +** `java.lang.Integer` +** `java.lang.Long` +** `java.lang.Float` +** `java.lang.Double` +** `java.lang.Character` +* `java.lang.String` +* `byte[]` +* `io.vertx.core.buffer.Buffer` +* implementations of `io.vertx.core.shareddata.ClusterSerializable` + +Sessions include a version number, so if multiple requests access the same session concurrently, only one of them will be able to persist the data back. +In general, sessions should not be accessed concurrently. + +Session data are stored back to the session store when the response headers are written. +This operation is asynchronous to the response body writing operation, so it is possible that persisting the session to the session store takes longer than committing the response. +This may especially be the case for tiny responses. + +=== General configuration + +include::{generated-dir}/config/quarkus-vertx-http-config-group-sessions-build-time-config.adoc[leveloffset=+1, opts=optional] + +include::{generated-dir}/config/quarkus-vertx-http-config-group-sessions-config.adoc[leveloffset=+1, opts=optional] + +=== In-memory sessions + +When `quarkus.http.sessions.mode` is set to `in-memory`, session data are stored in a Vert.x shared map. +By default, that shared map is _local_, which means that the session data are stored only in the JVM heap of the Quarkus application. + +In this mode, if an application is deployed in multiple replicas fronted with a load balancer, it is necessary to enable sticky sessions (also known as session affinity) on the load balancer. +Still, losing a replica means losing all sessions stored on that replica. +In a multi-replica deployment, it is recommended to use an external session store (Redis or Infinispan). + +Alternatively, if Vert.x clustering is configured, in-memory sessions may also be configured to be _cluster-wide_. +In this mode, a Vert.x _cluster-wide_ shared map is used to store session data, which means that sticky sessions are not necessary and losing one replica doesn't lead to session data loss. + +include::{generated-dir}/config/quarkus-http-sessions-in-memory-sessions-in-memory-config.adoc[leveloffset=+1, opts=optional] + +=== Redis sessions + +When `quarkus.http.sessions.mode` is set to `redis`, session data are stored in an external Redis server. +The xref:./redis.adoc[Quarkus Redis Client] extension must be present and a connection to the Redis server used to store session data must be configured there. + +By default, the default (unnamed) Redis connection is used. +To select a different (named) Redis connection, set the `quarkus.http.sessions.redis.client-name` configuration property. +For example: + +[source,properties] +---- +quarkus.http.sessions.mode=redis +quarkus.http.sessions.redis.client-name=web-sessions <1> + +quarkus.redis.web-sessions.hosts=redis://localhost:6379/7 <2> +---- +<1> Use the `web-sessions` Redis client for storing session data. +<2> Use database `7` on the Redis server at `localhost:6379`. + +The Redis-based session store requires an entire Redis database for itself. +When using a standalone Redis server, you can use a https://redis.io/commands/select/[logical database] that is not used for other purposes. +If you want to store session data into a Redis cluster, you need to dedicate an entire cluster, because Redis cluster only supports database zero. + +include::{generated-dir}/config/quarkus-redis-sessions.adoc[leveloffset=+1, opts=optional] + +=== Infinispan sessions + +When `quarkus.http.sessions.mode` is set to `infinispan`, session data are stored in an external Infinispan data grid. +The xref:./infinispan-client.adoc[Quarkus Infinispan Client] extension must be present and a connection to the Infinispan data grid used to store session data must be configured there. + +By default, the default (unnamed) Infinispan connection is used. +To select a different (named) Infinispan connection, set the `quarkus.http.sessions.infinispan.client-name` configuration property. +For example: + +[source,properties] +---- +quarkus.http.sessions.mode=infinispan +quarkus.http.sessions.infinispan.client-name=web-sessions <1> + +quarkus.infinispan-client.web-sessions.hosts=localhost:11222 <2> +---- +<1> Use the `web-sessions` Infinispan client for storing session data. +<2> Use the Infinispan data grid at `localhost:11222`. + +By default, the Infinispan cache used for storing session data is called `quarkus.sessions`. +To use a different cache, set the `quarkus.http.sessions.infinispan.cache-name` configuration property. + +The Infinispan session store verifies if the configured cache exists. +If it does not, it is created automatically from the `DIST_SYNC` default template. +To be able to do that, the Infinispan client must be configured to connect as a user with permissions equivalent to at least the `deployer` Infinispan role. + +include::{generated-dir}/config/quarkus-infinispan-sessions.adoc[leveloffset=+1, opts=optional] + +== Built-in route order values Route order values are the values that are specified via Vert.x route `io.vertx.ext.web.Route.order(int)` function. @@ -563,6 +688,7 @@ Route order constants defined in `io.quarkus.vertx.http.runtime.RouteConstants` | `Integer.MIN_VALUE` | `ROUTE_ORDER_BODY_HANDLER_MANAGEMENT` | Body handler for the management router. | `Integer.MIN_VALUE` | `ROUTE_ORDER_HEADERS` | Handlers that add headers specified in the configuration. | `Integer.MIN_VALUE` | `ROUTE_ORDER_CORS_MANAGEMENT` | CORS-Origin handler of the management router. +| `Integer.MIN_VALUE` | `ROUTE_ORDER_SESSION_HANDLER` | Session handler, if enabled in the configuration. | `Integer.MIN_VALUE + 1` | `ROUTE_ORDER_BODY_HANDLER` | Body handler. | `-2` | `ROUTE_ORDER_UPLOAD_LIMIT` | Route that enforces the upload body size limit. | `0` | `ROUTE_ORDER_COMPRESSION` | Compression handler. diff --git a/extensions/infinispan-client/pom.xml b/extensions/infinispan-client/pom.xml index 4bcaaeebab119..771ef999e2409 100644 --- a/extensions/infinispan-client/pom.xml +++ b/extensions/infinispan-client/pom.xml @@ -18,5 +18,8 @@ deployment-spi runtime runtime-spi + + sessions/deployment + sessions/runtime diff --git a/extensions/infinispan-client/runtime/pom.xml b/extensions/infinispan-client/runtime/pom.xml index 91440a2169ae3..adc82bcf5e15b 100644 --- a/extensions/infinispan-client/runtime/pom.xml +++ b/extensions/infinispan-client/runtime/pom.xml @@ -147,6 +147,11 @@ quarkus-kubernetes-service-binding true + + io.quarkus + quarkus-infinispan-client-sessions + true + io.quarkus quarkus-junit5-internal diff --git a/extensions/infinispan-client/sessions/deployment/pom.xml b/extensions/infinispan-client/sessions/deployment/pom.xml new file mode 100644 index 0000000000000..ee7cdc70de254 --- /dev/null +++ b/extensions/infinispan-client/sessions/deployment/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + + io.quarkus + quarkus-infinispan-client-parent + 999-SNAPSHOT + ../../pom.xml + + + quarkus-infinispan-client-sessions-deployment + + Quarkus - Infinispan Client - Vert.x Web Sessions - Deployment + + + io.quarkus + quarkus-infinispan-client-sessions + + + io.quarkus + quarkus-core-deployment + + + io.quarkus + quarkus-vertx-http-deployment + + + io.quarkus + quarkus-infinispan-client-deployment-spi + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + diff --git a/extensions/infinispan-client/sessions/deployment/src/main/java/io/quarkus/infinispan/sessions/deployment/InfinispanSessionsBuildTimeConfig.java b/extensions/infinispan-client/sessions/deployment/src/main/java/io/quarkus/infinispan/sessions/deployment/InfinispanSessionsBuildTimeConfig.java new file mode 100644 index 0000000000000..52f17f892d0ec --- /dev/null +++ b/extensions/infinispan-client/sessions/deployment/src/main/java/io/quarkus/infinispan/sessions/deployment/InfinispanSessionsBuildTimeConfig.java @@ -0,0 +1,23 @@ +package io.quarkus.infinispan.sessions.deployment; + +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; + +/** + * Configuration of Vert.x Web sessions stored in remote Infinispan cache. + */ +@ConfigRoot(name = "http.sessions.infinispan", phase = ConfigPhase.BUILD_TIME) +public class InfinispanSessionsBuildTimeConfig { + /** + * Name of the Infinispan client configured in the Quarkus Infinispan Client extension configuration. + * If not set, uses the default (unnamed) Infinispan client. + *

+ * Note that the Infinispan client must be configured to connect as a user with the necessary permissions + * on the Infinispan server. The required minimum is equivalent to the Infinispan {@code deployer} role. + */ + @ConfigItem + public Optional clientName; +} diff --git a/extensions/infinispan-client/sessions/deployment/src/main/java/io/quarkus/infinispan/sessions/deployment/InfinispanSessionsProcessor.java b/extensions/infinispan-client/sessions/deployment/src/main/java/io/quarkus/infinispan/sessions/deployment/InfinispanSessionsProcessor.java new file mode 100644 index 0000000000000..111c4c8b5c0b5 --- /dev/null +++ b/extensions/infinispan-client/sessions/deployment/src/main/java/io/quarkus/infinispan/sessions/deployment/InfinispanSessionsProcessor.java @@ -0,0 +1,46 @@ +package io.quarkus.infinispan.sessions.deployment; + +import java.util.List; + +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.infinispan.client.deployment.spi.InfinispanClientBuildItem; +import io.quarkus.infinispan.client.deployment.spi.InfinispanClientNameBuildItem; +import io.quarkus.infinispan.client.runtime.spi.InfinispanConstants; +import io.quarkus.infinispan.sessions.runtime.InfinispanSessionsRecorder; +import io.quarkus.vertx.http.deployment.SessionStoreProviderBuildItem; +import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.SessionsBuildTimeConfig; + +public class InfinispanSessionsProcessor { + @BuildStep + public void infinispanClients(HttpBuildTimeConfig httpConfig, + InfinispanSessionsBuildTimeConfig config, + BuildProducer infinispanRequest) { + if (httpConfig.sessions.mode == SessionsBuildTimeConfig.SessionsMode.INFINISPAN) { + String clientName = config.clientName.orElse(InfinispanConstants.DEFAULT_INFINISPAN_CLIENT_NAME); + infinispanRequest.produce(new InfinispanClientNameBuildItem(clientName)); + } + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + public void infinispanSessions(HttpBuildTimeConfig httpConfig, + InfinispanSessionsBuildTimeConfig config, + List clients, + BuildProducer provider, + InfinispanSessionsRecorder recorder) { + if (httpConfig.sessions.mode == SessionsBuildTimeConfig.SessionsMode.INFINISPAN) { + String clientName = config.clientName.orElse(InfinispanConstants.DEFAULT_INFINISPAN_CLIENT_NAME); + for (InfinispanClientBuildItem infinispanClient : clients) { + if (clientName.equals(infinispanClient.getName())) { + provider.produce(new SessionStoreProviderBuildItem(recorder.create(infinispanClient.getClient()))); + return; + } + } + throw new IllegalStateException("Unknown Infinispan client: " + clientName); + } + } +} diff --git a/extensions/infinispan-client/sessions/runtime/pom.xml b/extensions/infinispan-client/sessions/runtime/pom.xml new file mode 100644 index 0000000000000..fd9d9dd2dad3b --- /dev/null +++ b/extensions/infinispan-client/sessions/runtime/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + + io.quarkus + quarkus-infinispan-client-parent + 999-SNAPSHOT + ../../pom.xml + + + quarkus-infinispan-client-sessions + + Quarkus - Infinispan Client - Vert.x Web Sessions - Runtime + + + io.quarkus + quarkus-core + + + io.quarkus + quarkus-vertx-http + + + io.vertx + vertx-web-sstore-infinispan + + + org.infinispan + infinispan-client-hotrod + + + io.reactivex.rxjava3 + rxjava + + + + + org.infinispan + infinispan-client-hotrod-jakarta + + + org.infinispan + infinispan-jboss-marshalling + + + org.jboss.spec.javax.transaction + jboss-transaction-api_1.2_spec + + + io.netty + netty-transport-native-epoll + + + + + + + + + io.quarkus + quarkus-extension-maven-plugin + + + io.quarkus:quarkus-vertx-http + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + diff --git a/extensions/infinispan-client/sessions/runtime/src/main/java/io/quarkus/infinispan/sessions/runtime/InfinispanSessionsConfig.java b/extensions/infinispan-client/sessions/runtime/src/main/java/io/quarkus/infinispan/sessions/runtime/InfinispanSessionsConfig.java new file mode 100644 index 0000000000000..56c332f2d1b36 --- /dev/null +++ b/extensions/infinispan-client/sessions/runtime/src/main/java/io/quarkus/infinispan/sessions/runtime/InfinispanSessionsConfig.java @@ -0,0 +1,28 @@ +package io.quarkus.infinispan.sessions.runtime; + +import java.time.Duration; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; + +/** + * Configuration of Vert.x Web sessions stored in remote Infinispan cache. + */ +@ConfigRoot(name = "http.sessions.infinispan", phase = ConfigPhase.RUN_TIME) +public class InfinispanSessionsConfig { + /** + * Name of the Infinispan cache used to store session data. If it does not exist, it is created + * automatically from Infinispan's default template {@code DIST_SYNC}. + */ + @ConfigItem(defaultValue = "quarkus.sessions") + public String cacheName; + + /** + * Maximum time to retry when retrieving session data from the Infinispan cache. + * The Vert.x session handler retries when the session data are not found, because + * distributing data across an Infinispan cluster may take time. + */ + @ConfigItem(defaultValue = "5s") + public Duration retryTimeout; +} diff --git a/extensions/infinispan-client/sessions/runtime/src/main/java/io/quarkus/infinispan/sessions/runtime/InfinispanSessionsRecorder.java b/extensions/infinispan-client/sessions/runtime/src/main/java/io/quarkus/infinispan/sessions/runtime/InfinispanSessionsRecorder.java new file mode 100644 index 0000000000000..c82b315dfb0a7 --- /dev/null +++ b/extensions/infinispan-client/sessions/runtime/src/main/java/io/quarkus/infinispan/sessions/runtime/InfinispanSessionsRecorder.java @@ -0,0 +1,38 @@ +package io.quarkus.infinispan.sessions.runtime; + +import java.time.Duration; +import java.util.function.Supplier; + +import org.infinispan.client.hotrod.RemoteCacheManager; + +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.vertx.core.runtime.VertxCoreRecorder; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.sstore.SessionStore; +import io.vertx.ext.web.sstore.infinispan.InfinispanSessionStore; + +@Recorder +public class InfinispanSessionsRecorder { + private final RuntimeValue config; + + public InfinispanSessionsRecorder(RuntimeValue config) { + this.config = config; + } + + public Supplier create(RuntimeValue client) { + return new Supplier() { + @Override + public SessionStore get() { + Vertx vertx = VertxCoreRecorder.getVertx().get(); + String cacheName = config.getValue().cacheName; + Duration retryTimeout = config.getValue().retryTimeout; + JsonObject options = new JsonObject() + .put("cacheName", cacheName) + .put("retryTimeout", retryTimeout.toMillis()); + return InfinispanSessionStore.create(vertx, options, client.getValue()); + } + }; + } +} diff --git a/extensions/infinispan-client/sessions/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/infinispan-client/sessions/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000000..476e398efc5ae --- /dev/null +++ b/extensions/infinispan-client/sessions/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,13 @@ +--- +artifact: ${project.groupId}:${project.artifactId}:${project.version} +name: "Infinispan Client - Vert.x Web Sessions" +metadata: + keywords: + - "infinispan" + - "vertx" + - "sessions" + guide: "https://quarkus.io/guides/http-reference#vertx-web-sessions" + categories: + - "web" + status: "preview" + unlisted: true diff --git a/extensions/redis-client/pom.xml b/extensions/redis-client/pom.xml index c861fb139cc9f..543702fa281ba 100644 --- a/extensions/redis-client/pom.xml +++ b/extensions/redis-client/pom.xml @@ -21,6 +21,9 @@ deployment-spi runtime runtime-spi + + sessions/deployment + sessions/runtime diff --git a/extensions/redis-client/runtime/pom.xml b/extensions/redis-client/runtime/pom.xml index 3dd920a374e64..2ae90adb17c23 100644 --- a/extensions/redis-client/runtime/pom.xml +++ b/extensions/redis-client/runtime/pom.xml @@ -37,6 +37,11 @@ quarkus-smallrye-health true + + io.quarkus + quarkus-redis-client-sessions + true + org.assertj assertj-core diff --git a/extensions/redis-client/sessions/deployment/pom.xml b/extensions/redis-client/sessions/deployment/pom.xml new file mode 100644 index 0000000000000..16b78ba733b58 --- /dev/null +++ b/extensions/redis-client/sessions/deployment/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + + io.quarkus + quarkus-redis-client-parent + 999-SNAPSHOT + ../../pom.xml + + + quarkus-redis-client-sessions-deployment + + Quarkus - Redis Client - Vert.x Web Sessions - Deployment + + + io.quarkus + quarkus-redis-client-sessions + + + io.quarkus + quarkus-core-deployment + + + io.quarkus + quarkus-vertx-http-deployment + + + io.quarkus + quarkus-redis-client-deployment-spi + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + diff --git a/extensions/redis-client/sessions/deployment/src/main/java/io/quarkus/redis/sessions/deployment/RedisSessionsBuildTimeConfig.java b/extensions/redis-client/sessions/deployment/src/main/java/io/quarkus/redis/sessions/deployment/RedisSessionsBuildTimeConfig.java new file mode 100644 index 0000000000000..caf5991dcba04 --- /dev/null +++ b/extensions/redis-client/sessions/deployment/src/main/java/io/quarkus/redis/sessions/deployment/RedisSessionsBuildTimeConfig.java @@ -0,0 +1,20 @@ +package io.quarkus.redis.sessions.deployment; + +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; + +/** + * Configuration of Vert.x Web sessions stored in Redis. + */ +@ConfigRoot(name = "http.sessions.redis", phase = ConfigPhase.BUILD_TIME) +public class RedisSessionsBuildTimeConfig { + /** + * Name of the Redis client configured in the Quarkus Redis extension configuration. + * If not set, uses the default (unnamed) Redis client. + */ + @ConfigItem + public Optional clientName; +} diff --git a/extensions/redis-client/sessions/deployment/src/main/java/io/quarkus/redis/sessions/deployment/RedisSessionsProcessor.java b/extensions/redis-client/sessions/deployment/src/main/java/io/quarkus/redis/sessions/deployment/RedisSessionsProcessor.java new file mode 100644 index 0000000000000..2d6f3091ac120 --- /dev/null +++ b/extensions/redis-client/sessions/deployment/src/main/java/io/quarkus/redis/sessions/deployment/RedisSessionsProcessor.java @@ -0,0 +1,46 @@ +package io.quarkus.redis.sessions.deployment; + +import java.util.List; + +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.redis.deployment.client.spi.RedisClientBuildItem; +import io.quarkus.redis.deployment.client.spi.RequestedRedisClientBuildItem; +import io.quarkus.redis.runtime.spi.RedisConstants; +import io.quarkus.redis.sessions.runtime.RedisSessionsRecorder; +import io.quarkus.vertx.http.deployment.SessionStoreProviderBuildItem; +import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.SessionsBuildTimeConfig; + +public class RedisSessionsProcessor { + @BuildStep + public void redisClients(HttpBuildTimeConfig httpConfig, + RedisSessionsBuildTimeConfig config, + BuildProducer redisRequest) { + if (httpConfig.sessions.mode == SessionsBuildTimeConfig.SessionsMode.REDIS) { + String clientName = config.clientName.orElse(RedisConstants.DEFAULT_CLIENT_NAME); + redisRequest.produce(new RequestedRedisClientBuildItem(clientName)); + } + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + public void redisSessions(HttpBuildTimeConfig httpConfig, + RedisSessionsBuildTimeConfig config, + List clients, + BuildProducer provider, + RedisSessionsRecorder recorder) { + if (httpConfig.sessions.mode == SessionsBuildTimeConfig.SessionsMode.REDIS) { + String clientName = config.clientName.orElse(RedisConstants.DEFAULT_CLIENT_NAME); + for (RedisClientBuildItem redisClient : clients) { + if (clientName.equals(redisClient.getName())) { + provider.produce(new SessionStoreProviderBuildItem(recorder.create(redisClient.getClient()))); + return; + } + } + throw new IllegalStateException("Unknown Redis client: " + clientName); + } + } +} diff --git a/extensions/redis-client/sessions/runtime/pom.xml b/extensions/redis-client/sessions/runtime/pom.xml new file mode 100644 index 0000000000000..6490629ee0e02 --- /dev/null +++ b/extensions/redis-client/sessions/runtime/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + + io.quarkus + quarkus-redis-client-parent + 999-SNAPSHOT + ../../pom.xml + + + quarkus-redis-client-sessions + + Quarkus - Redis Client - Vert.x Web Sessions - Runtime + + + io.quarkus + quarkus-core + + + io.quarkus + quarkus-vertx-http + + + io.vertx + vertx-web-sstore-redis + + + io.smallrye.reactive + smallrye-mutiny-vertx-redis-client + + + + + + + io.quarkus + quarkus-extension-maven-plugin + + + io.quarkus:quarkus-vertx-http + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + diff --git a/extensions/redis-client/sessions/runtime/src/main/java/io/quarkus/redis/sessions/runtime/RedisSessionsConfig.java b/extensions/redis-client/sessions/runtime/src/main/java/io/quarkus/redis/sessions/runtime/RedisSessionsConfig.java new file mode 100644 index 0000000000000..defd5be8cded9 --- /dev/null +++ b/extensions/redis-client/sessions/runtime/src/main/java/io/quarkus/redis/sessions/runtime/RedisSessionsConfig.java @@ -0,0 +1,21 @@ +package io.quarkus.redis.sessions.runtime; + +import java.time.Duration; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; + +/** + * Configuration of Vert.x Web sessions stored in Redis. + */ +@ConfigRoot(name = "http.sessions.redis", phase = ConfigPhase.RUN_TIME) +public class RedisSessionsConfig { + /** + * Maximum time to retry when retrieving session data from the Redis server. + * The Vert.x session handler retries when the session data are not found, because + * distributing data across a potential Redis cluster may take some time. + */ + @ConfigItem(defaultValue = "2s") + public Duration retryTimeout; +} diff --git a/extensions/redis-client/sessions/runtime/src/main/java/io/quarkus/redis/sessions/runtime/RedisSessionsRecorder.java b/extensions/redis-client/sessions/runtime/src/main/java/io/quarkus/redis/sessions/runtime/RedisSessionsRecorder.java new file mode 100644 index 0000000000000..3608e74acc464 --- /dev/null +++ b/extensions/redis-client/sessions/runtime/src/main/java/io/quarkus/redis/sessions/runtime/RedisSessionsRecorder.java @@ -0,0 +1,32 @@ +package io.quarkus.redis.sessions.runtime; + +import java.time.Duration; +import java.util.function.Supplier; + +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.vertx.core.runtime.VertxCoreRecorder; +import io.vertx.core.Vertx; +import io.vertx.ext.web.sstore.SessionStore; +import io.vertx.ext.web.sstore.redis.RedisSessionStore; +import io.vertx.mutiny.redis.client.Redis; + +@Recorder +public class RedisSessionsRecorder { + private final RuntimeValue config; + + public RedisSessionsRecorder(RuntimeValue config) { + this.config = config; + } + + public Supplier create(Supplier client) { + return new Supplier() { + @Override + public SessionStore get() { + Vertx vertx = VertxCoreRecorder.getVertx().get(); + Duration retryTimeout = config.getValue().retryTimeout; + return RedisSessionStore.create(vertx, retryTimeout.toMillis(), client.get().getDelegate()); + } + }; + } +} diff --git a/extensions/redis-client/sessions/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/redis-client/sessions/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000000..4f4ef6cbe8e9b --- /dev/null +++ b/extensions/redis-client/sessions/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,13 @@ +--- +artifact: ${project.groupId}:${project.artifactId}:${project.version} +name: "Redis Client - Vert.x Web Sessions" +metadata: + keywords: + - "redis" + - "vertx" + - "sessions" + guide: "https://quarkus.io/guides/http-reference#vertx-web-sessions" + categories: + - "web" + status: "preview" + unlisted: true diff --git a/extensions/vertx-http/deployment/pom.xml b/extensions/vertx-http/deployment/pom.xml index ed3dd67a09031..8d3eff0acb0e0 100644 --- a/extensions/vertx-http/deployment/pom.xml +++ b/extensions/vertx-http/deployment/pom.xml @@ -33,7 +33,7 @@ io.quarkus quarkus-kubernetes-spi - + io.quarkus diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/SessionStoreProviderBuildItem.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/SessionStoreProviderBuildItem.java new file mode 100644 index 0000000000000..d3a9a87fc1389 --- /dev/null +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/SessionStoreProviderBuildItem.java @@ -0,0 +1,24 @@ +package io.quarkus.vertx.http.deployment; + +import java.util.Objects; +import java.util.function.Supplier; + +import io.quarkus.builder.item.MultiBuildItem; +import io.vertx.ext.web.sstore.SessionStore; + +/** + * This is a {@code MultiBuildItem} so that multiple producers may exist + * among the set of currently present extensions. However, at most one item + * of this type may be produced. + */ +public final class SessionStoreProviderBuildItem extends MultiBuildItem { + private final Supplier provider; + + public SessionStoreProviderBuildItem(Supplier provider) { + this.provider = Objects.requireNonNull(provider); + } + + public Supplier getProvider() { + return provider; + } +} diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/VertxHttpProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/VertxHttpProcessor.java index fd8ec2b3ce36e..233f14893dcd1 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/VertxHttpProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/VertxHttpProcessor.java @@ -9,6 +9,7 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.function.Supplier; import java.util.logging.Level; import java.util.stream.Collectors; @@ -60,6 +61,7 @@ import io.quarkus.vertx.http.runtime.CurrentVertxRequest; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; import io.quarkus.vertx.http.runtime.HttpConfiguration; +import io.quarkus.vertx.http.runtime.SessionsBuildTimeConfig; import io.quarkus.vertx.http.runtime.VertxConfigBuilder; import io.quarkus.vertx.http.runtime.VertxHttpRecorder; import io.quarkus.vertx.http.runtime.attribute.ExchangeAttributeBuilder; @@ -72,6 +74,7 @@ import io.vertx.core.impl.VertxImpl; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.sstore.SessionStore; class VertxHttpProcessor { @@ -268,7 +271,7 @@ VertxWebRouterBuildItem initializeRouter(VertxHttpRecorder recorder, } } - /** + /* * To create mainrouter when `${quarkus.http.root-path}` is not {@literal /} * Refer https://github.com/quarkusio/quarkus/issues/34261 */ @@ -314,7 +317,9 @@ ServiceStartBuildItem finalizeRouter( ShutdownConfig shutdownConfig, LiveReloadConfig lrc, CoreVertxBuildItem core, // Injected to be sure that Vert.x has been produced before calling this method. - ExecutorBuildItem executorBuildItem) + ExecutorBuildItem executorBuildItem, + List sessionStoreProvider, + Capabilities capabilities) throws BuildException, IOException { Optional defaultRoute; @@ -366,6 +371,41 @@ ServiceStartBuildItem finalizeRouter( } } + if (httpBuildTimeConfig.sessions.mode != SessionsBuildTimeConfig.SessionsMode.DISABLED + && capabilities.isPresent(Capability.SERVLET)) { + throw new IllegalStateException("Vert.x Web sessions may not be enabled together with Undertow; " + + "use Undertow (servlet) sessions instead"); + } + + Supplier sessionStore = null; + switch (httpBuildTimeConfig.sessions.mode) { + case DISABLED: + break; + case IN_MEMORY: + sessionStore = recorder.createInMemorySessionStore(); + break; + case REDIS: + if (sessionStoreProvider.isEmpty()) { + throw new IllegalStateException("Redis-based session store was configured, " + + "but the Quarkus Redis Client extension is missing"); + } + if (sessionStoreProvider.size() > 1) { + throw new IllegalStateException("Internal error, multiple session store providers exist"); + } + sessionStore = sessionStoreProvider.get(0).getProvider(); + break; + case INFINISPAN: + if (sessionStoreProvider.isEmpty()) { + throw new IllegalStateException("Infinispan-based session store was configured, " + + "but the Quarkus Infinispan Client extension is missing"); + } + if (sessionStoreProvider.size() > 1) { + throw new IllegalStateException("Internal error, multiple session store providers exist"); + } + sessionStore = sessionStoreProvider.get(0).getProvider(); + break; + } + recorder.finalizeRouter(beanContainer.getValue(), defaultRoute.map(DefaultRouteBuildItem::getRoute).orElse(null), listOfFilters, listOfManagementInterfaceFilters, @@ -376,7 +416,8 @@ ServiceStartBuildItem finalizeRouter( nonApplicationRootPathBuildItem.getNonApplicationRootPath(), launchMode.getLaunchMode(), !requireBodyHandlerBuildItems.isEmpty(), bodyHandler, gracefulShutdownFilter, - shutdownConfig, executorBuildItem.getExecutorProxy()); + shutdownConfig, executorBuildItem.getExecutorProxy(), + sessionStore); return new ServiceStartBuildItem("vertx-http"); } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/CurrentVertxRequest.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/CurrentVertxRequest.java index 217c01185a875..85d077e91ae4f 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/CurrentVertxRequest.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/CurrentVertxRequest.java @@ -4,6 +4,7 @@ import jakarta.enterprise.inject.Produces; import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.Session; @RequestScoped public class CurrentVertxRequest { @@ -17,6 +18,16 @@ public RoutingContext getCurrent() { return current; } + @Produces + @RequestScoped + public Session getCurrentSession() { + Session result = current.session(); + if (result == null) { + throw new UnsupportedOperationException("No active session or support for sessions disabled"); + } + return result; + } + public CurrentVertxRequest setCurrent(RoutingContext current) { this.current = current; return this; diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpBuildTimeConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpBuildTimeConfig.java index c1a2819bd3a88..99d2f1e6a8ebc 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpBuildTimeConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpBuildTimeConfig.java @@ -98,4 +98,10 @@ public class HttpBuildTimeConfig { */ @ConfigItem public OptionalInt compressionLevel; + + /** + * Configuration of Vert.x Web sessions. + */ + @ConfigItem + public SessionsBuildTimeConfig sessions; } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java index 89ffdf53d0c19..749c01bad42c6 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java @@ -265,6 +265,11 @@ public class HttpConfiguration { @ConfigItem public Map filter; + /** + * Configuration of Vert.x Web sessions. + */ + public SessionsConfig sessions; + public ProxyConfig proxy; public int determinePort(LaunchMode launchMode) { diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/RouteConstants.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/RouteConstants.java index 6d00a3afa9b07..dd8c286dc94f7 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/RouteConstants.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/RouteConstants.java @@ -1,7 +1,7 @@ package io.quarkus.vertx.http.runtime; /** - * Route order value constants used in Quarkus, update {@code reactive-routes.adoc} when changing this class. + * Route order value constants used in Quarkus, update {@code http-reference.adoc} when changing this class. */ @SuppressWarnings("JavadocDeclaration") public final class RouteConstants { @@ -33,6 +33,10 @@ private RouteConstants() { * Order value ({@value #ROUTE_ORDER_CORS_MANAGEMENT}) for the CORS-Origin handler of the management router. */ public static final int ROUTE_ORDER_CORS_MANAGEMENT = Integer.MIN_VALUE; + /** + * Order value ({@value #ROUTE_ORDER_SESSION_HANDLER}) for the session handler, if enabled in the configuration. + */ + public static final int ROUTE_ORDER_SESSION_HANDLER = Integer.MIN_VALUE; /** * Order value ({@value #ROUTE_ORDER_BODY_HANDLER}) for the body handler. */ diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/SessionsBuildTimeConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/SessionsBuildTimeConfig.java new file mode 100644 index 0000000000000..204de777d1e9a --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/SessionsBuildTimeConfig.java @@ -0,0 +1,46 @@ +package io.quarkus.vertx.http.runtime; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +/** + * Configuration of Vert.x Web sessions. + */ +@ConfigGroup +public class SessionsBuildTimeConfig { + /** + * Whether Vert.x Web support for sessions is enabled (the {@code SessionHandler} is added to the router) + * and if so, which session store is used. For the {@code redis} and {@code infinispan} modes, the corresponding + * Quarkus extension must be present and a connection to the data store must be configured there. + */ + @ConfigItem(defaultValue = "disabled") + public SessionsMode mode; + + public enum SessionsMode { + /** + * Support for Vert.x Web sessions is disabled. + */ + DISABLED, + /** + * Support for Vert.x Web sessions is enabled and sessions are stored in memory. + * In this mode, if an application is deployed in multiple replicas fronted with a load balancer, + * it is necessary to enable sticky sessions (also known as session affinity) on the load balancer. + * Still, losing a replica means losing all sessions stored on that replica. + * In a multi-replica deployment, it is recommended to use an external session store (Redis or Infinispan). + * Alternatively, if Vert.x clustering is enabled, in-memory sessions may be configured to be stored + * cluster-wide, which also makes sticky sessions not necessary and prevents session data loss + * (depending on the Vert.x cluster manager configuration). + */ + IN_MEMORY, + /** + * Support for Vert.x Web sessions is enabled and sessions are stored in a remote Redis server. + * The Quarkus Redis Client extension must be present and a Redis connection must be configured. + */ + REDIS, + /** + * Support for Vert.x Web sessions is enabled and sessions are stored in a remote Infinispan cache. + * The Quarkus Infinispan Client extension must be present and an Infinispan connection must be configured. + */ + INFINISPAN, + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/SessionsConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/SessionsConfig.java new file mode 100644 index 0000000000000..c53fbbdbf1693 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/SessionsConfig.java @@ -0,0 +1,98 @@ +package io.quarkus.vertx.http.runtime; + +import java.time.Duration; +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; +import io.vertx.core.http.CookieSameSite; + +/** + * Configuration of Vert.x Web sessions. + */ +@ConfigGroup +public class SessionsConfig { + /** + * The session timeout. + */ + @ConfigItem(defaultValue = "30M") + public Duration timeout; + + /** + * The requested length of the session identifier. + */ + @ConfigItem(defaultValue = "16") + public int idLength; + + /** + * The session cookie path. The value is relative to {@code quarkus.http.root-path}. + */ + @ConfigItem(defaultValue = "/") + public String path; + + /** + * The name of the session cookie. + */ + @ConfigItem(defaultValue = "JSESSIONID") + public String cookieName; + + /** + * Whether the session cookie has the {@code HttpOnly} attribute. + */ + @ConfigItem(defaultValue = "true") + public boolean cookieHttpOnly; + + /** + * Whether the session cookie has the {@code Secure} attribute. + *

+ */ + @ConfigItem(defaultValue = "auto") + public SessionCookieSecure cookieSecure; + + /** + * The value of the {@code SameSite} attribute of the session cookie. + * By default, the {@code SameSite} attribute is not present. + */ + @ConfigItem + public Optional cookieSameSite; + + /** + * The {@code Max-Age} attribute of the session cookie. Note that setting this option turns the session cookie + * into a persistent cookie. + */ + @ConfigItem + public Optional cookieMaxAge; + + public enum SessionCookieSecure { + /** + * The session cookie only has the {@code Secure} attribute when {@code quarkus.http.insecure-requests} + * is {@code redirect} or {@code disabled}. If {@code insecure-requests} is {@code enabled}, the session cookie + * does not have the {@code Secure} attribute. + */ + AUTO, + /** + * The session cookie always has the {@code Secure} attribute. + */ + ALWAYS, + /** + * The session cookie never has the {@code Secure} attribute. + */ + NEVER; + + boolean isEnabled(HttpConfiguration.InsecureRequests insecureRequests) { + if (this == ALWAYS) { + return true; + } else if (this == NEVER) { + return false; + } else { + return insecureRequests != HttpConfiguration.InsecureRequests.ENABLED; + } + } + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/SessionsInMemoryConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/SessionsInMemoryConfig.java new file mode 100644 index 0000000000000..b2592bf2c0afd --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/SessionsInMemoryConfig.java @@ -0,0 +1,37 @@ +package io.quarkus.vertx.http.runtime; + +import java.time.Duration; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; + +/** + * Configuration of Vert.x Web sessions stored in memory. + */ +@ConfigRoot(name = "http.sessions.in-memory", phase = ConfigPhase.RUN_TIME) +public class SessionsInMemoryConfig { + /** + * Name of the Vert.x local map or cluster-wide map to store the session data. + */ + @ConfigItem(defaultValue = "quarkus.sessions") + public String mapName; + + /** + * Whether in-memory sessions are stored cluster-wide. + *

+ * Ignored when Vert.x clustering is not enabled. + */ + @ConfigItem(defaultValue = "false") + public boolean clusterWide; + + /** + * Maximum time to retry when retrieving session data from the cluster-wide map. + * The Vert.x session handler retries when the session data are not found, because + * distributing data across the cluster may take time. + *

+ * Ignored when in-memory sessions are not cluster-wide. + */ + @ConfigItem(defaultValue = "5s") + public Duration retryTimeout; +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java index fbcb893f48b9d..bd50392bc2633 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java @@ -7,6 +7,7 @@ import java.net.BindException; import java.net.URI; import java.net.URISyntaxException; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -104,6 +105,10 @@ import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.BodyHandler; import io.vertx.ext.web.handler.CorsHandler; +import io.vertx.ext.web.handler.SessionHandler; +import io.vertx.ext.web.sstore.ClusteredSessionStore; +import io.vertx.ext.web.sstore.LocalSessionStore; +import io.vertx.ext.web.sstore.SessionStore; @Recorder public class VertxHttpRecorder { @@ -187,18 +192,25 @@ private boolean uriValid(HttpServerRequest httpServerRequest) { final HttpBuildTimeConfig httpBuildTimeConfig; final ManagementInterfaceBuildTimeConfig managementBuildTimeConfig; final RuntimeValue httpConfiguration; + final RuntimeValue inMemorySessionsConfiguration; final RuntimeValue managementConfiguration; private static volatile Handler managementRouter; + final RuntimeValue vertxConfiguration; + public VertxHttpRecorder(HttpBuildTimeConfig httpBuildTimeConfig, ManagementInterfaceBuildTimeConfig managementBuildTimeConfig, RuntimeValue httpConfiguration, - RuntimeValue managementConfiguration) { + RuntimeValue inMemorySessionsConfiguration, + RuntimeValue managementConfiguration, + RuntimeValue vertxConfiguration) { this.httpBuildTimeConfig = httpBuildTimeConfig; this.httpConfiguration = httpConfiguration; + this.inMemorySessionsConfiguration = inMemorySessionsConfiguration; this.managementBuildTimeConfig = managementBuildTimeConfig; this.managementConfiguration = managementConfiguration; + this.vertxConfiguration = vertxConfiguration; } public static void setHotReplacement(Handler handler, HotReplacementContext hrc) { @@ -346,6 +358,23 @@ public void mountFrameworkRouter(RuntimeValue mainRouter, RuntimeValue createInMemorySessionStore() { + return new Supplier() { + @Override + public SessionStore get() { + Vertx vertx = VertxCoreRecorder.getVertx().get(); + SessionsInMemoryConfig config = inMemorySessionsConfiguration.getValue(); + if (config.clusterWide + && vertxConfiguration.getValue().cluster() != null + && vertxConfiguration.getValue().cluster().clustered()) { + return ClusteredSessionStore.create(vertx, config.mapName, config.retryTimeout.toMillis()); + } else { + return LocalSessionStore.create(vertx, config.mapName); + } + } + }; + } + public void finalizeRouter(BeanContainer container, Consumer defaultRouteHandler, List filterList, List managementInterfaceFilterList, Supplier vertx, LiveReloadConfig liveReloadConfig, Optional> mainRouterRuntimeValue, @@ -355,7 +384,7 @@ public void finalizeRouter(BeanContainer container, Consumer defaultRoute LaunchMode launchMode, boolean requireBodyHandler, Handler bodyHandler, GracefulShutdownFilter gracefulShutdownFilter, ShutdownConfig shutdownConfig, - Executor executor) { + Executor executor, Supplier sessionStore) { HttpConfiguration httpConfiguration = this.httpConfiguration.getValue(); // install the default route at the end Router httpRouteRouter = httpRouterRuntimeValue.getValue(); @@ -413,6 +442,28 @@ public void handle(RoutingContext routingContext) { // Headers sent on any request, regardless of the response HttpServerCommonHandlers.applyHeaders(httpConfiguration.header, httpRouteRouter); + if (sessionStore != null) { + SessionsConfig sessions = httpConfiguration.sessions; + String cookiePath; + if (sessions.path.isEmpty() || "/".equals(sessions.path)) { + cookiePath = rootPath.endsWith("/") ? rootPath.substring(0, rootPath.length() - 1) : rootPath; + } else { + cookiePath = rootPath + + (rootPath.endsWith("/") ? "" : "/") + + (sessions.path.startsWith("/") ? sessions.path.substring(1) : sessions.path); + } + SessionHandler sessionHandler = SessionHandler.create(sessionStore.get()) + .setSessionTimeout(sessions.timeout.toMillis()) + .setMinLength(sessions.idLength) + .setSessionCookiePath(cookiePath) + .setSessionCookieName(sessions.cookieName) + .setCookieHttpOnlyFlag(sessions.cookieHttpOnly) + .setCookieSecureFlag(sessions.cookieSecure.isEnabled(httpConfiguration.insecureRequests)) + .setCookieSameSite(sessions.cookieSameSite.orElse(null)) + .setCookieMaxAge(sessions.cookieMaxAge.map(Duration::toMillis).orElse(-1L)); + httpRouteRouter.route().order(RouteConstants.ROUTE_ORDER_ACCESS_LOG_HANDLER).handler(sessionHandler); + } + Handler root; if (rootPath.equals("/")) { if (hotReplacementHandler != null) { diff --git a/integration-tests/infinispan-client/src/main/java/io/quarkus/it/infinispan/client/websessions/CounterResource.java b/integration-tests/infinispan-client/src/main/java/io/quarkus/it/infinispan/client/websessions/CounterResource.java new file mode 100644 index 0000000000000..d5f008f62cb1b --- /dev/null +++ b/integration-tests/infinispan-client/src/main/java/io/quarkus/it/infinispan/client/websessions/CounterResource.java @@ -0,0 +1,21 @@ +package io.quarkus.it.infinispan.client.websessions; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.vertx.ext.web.Session; + +@Path("/counter") +public class CounterResource { + @Inject + Session session; + + @GET + public String counter() { + Integer counter = session.get("counter"); + counter = counter == null ? 1 : counter + 1; + session.put("counter", counter); + return session.id() + "|" + counter; + } +} diff --git a/integration-tests/infinispan-client/src/main/resources/application.properties b/integration-tests/infinispan-client/src/main/resources/application.properties index d9e2391da36e8..d1dfb6129874e 100644 --- a/integration-tests/infinispan-client/src/main/resources/application.properties +++ b/integration-tests/infinispan-client/src/main/resources/application.properties @@ -21,3 +21,4 @@ quarkus.infinispan-client.another.devservices.mcast-port=46667 quarkus.infinispan-client.another.devservices.port=31223 quarkus.infinispan-client.another.devservices.service-name=infinispanAnother +quarkus.http.sessions.mode=infinispan diff --git a/integration-tests/infinispan-client/src/test/java/io/quarkus/it/infinispan/client/websessions/CounterTest.java b/integration-tests/infinispan-client/src/test/java/io/quarkus/it/infinispan/client/websessions/CounterTest.java new file mode 100644 index 0000000000000..28cfe99241e1f --- /dev/null +++ b/integration-tests/infinispan-client/src/test/java/io/quarkus/it/infinispan/client/websessions/CounterTest.java @@ -0,0 +1,93 @@ +package io.quarkus.it.infinispan.client.websessions; + +import static io.restassured.RestAssured.with; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.filter.session.SessionFilter; +import io.restassured.response.Response; + +@QuarkusTest +public class CounterTest { + @Test + public void test() throws InterruptedException { + List users = new ArrayList<>(); + for (int i = 0; i < 50; i++) { + users.add(new User(20)); + } + + for (User user : users) { + user.start(); + } + for (User user : users) { + user.join(); + } + for (User user : users) { + user.verify(); + } + } + + static class User extends Thread { + private static final AtomicInteger counter = new AtomicInteger(); + + private final Set sessionIds = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private final Queue responses = new ConcurrentLinkedQueue<>(); + + private final int requests; + + User(int requests) { + super("User" + counter.incrementAndGet()); + this.requests = requests; + } + + @Override + public void run() { + SessionFilter sessions = new SessionFilter(); + for (int i = 0; i < requests; i++) { + Response response = with().filter(sessions).get("/counter"); + if (response.sessionId() != null) { + sessionIds.add(response.sessionId()); + } + responses.add(response.body().asString()); + + try { + // need to sleep longer to give the session store some time to finish + // + // the operation to store session data into Infinispan is fired off when response headers are written, + // but there's nothing waiting for that operation to complete when the response is being sent + // + // therefore, if we send a 2nd request too quickly after receiving the 1st response, + // the session data may still be in the process of being stored and the 2nd request + // would get stale session data + Thread.sleep(500 + ThreadLocalRandom.current().nextInt(500)); + } catch (InterruptedException e) { + return; + } + } + } + + public void verify() { + assertEquals(1, sessionIds.size()); + String id = sessionIds.iterator().next(); + + assertEquals(requests, responses.size()); + int i = 1; + for (String response : responses) { + assertEquals(id + "|" + i, response); + i++; + } + } + } +} diff --git a/integration-tests/redis-client/src/main/java/io/quarkus/redis/it/websessions/CounterResource.java b/integration-tests/redis-client/src/main/java/io/quarkus/redis/it/websessions/CounterResource.java new file mode 100644 index 0000000000000..ce4e905d1bb00 --- /dev/null +++ b/integration-tests/redis-client/src/main/java/io/quarkus/redis/it/websessions/CounterResource.java @@ -0,0 +1,21 @@ +package io.quarkus.redis.it.websessions; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.vertx.ext.web.Session; + +@Path("/counter") +public class CounterResource { + @Inject + Session session; + + @GET + public String counter() { + Integer counter = session.get("counter"); + counter = counter == null ? 1 : counter + 1; + session.put("counter", counter); + return session.id() + "|" + counter; + } +} diff --git a/integration-tests/redis-client/src/main/resources/application.properties b/integration-tests/redis-client/src/main/resources/application.properties index 6bb245e908d5e..caaa993568770 100644 --- a/integration-tests/redis-client/src/main/resources/application.properties +++ b/integration-tests/redis-client/src/main/resources/application.properties @@ -8,4 +8,10 @@ quarkus.redis.instance-client.hosts=redis://localhost:6379/5 # use DB 3 quarkus.redis.provided-hosts.hosts-provider-name=test-hosts-provider -quarkus.redis.load-script=starwars.redis \ No newline at end of file +quarkus.redis.load-script=starwars.redis + +quarkus.redis.web-sessions.hosts=redis://localhost:6379/7 +quarkus.redis.web-sessions.max-pool-waiting=100 + +quarkus.http.sessions.mode=redis +quarkus.http.sessions.redis.client-name=web-sessions diff --git a/integration-tests/redis-client/src/test/java/io/quarkus/redis/it/websessions/CounterTest.java b/integration-tests/redis-client/src/test/java/io/quarkus/redis/it/websessions/CounterTest.java new file mode 100644 index 0000000000000..0de4a5b639809 --- /dev/null +++ b/integration-tests/redis-client/src/test/java/io/quarkus/redis/it/websessions/CounterTest.java @@ -0,0 +1,93 @@ +package io.quarkus.redis.it.websessions; + +import static io.restassured.RestAssured.with; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.filter.session.SessionFilter; +import io.restassured.response.Response; + +@QuarkusTest +public class CounterTest { + @Test + public void test() throws InterruptedException { + List users = new ArrayList<>(); + for (int i = 0; i < 50; i++) { + users.add(new User(20)); + } + + for (User user : users) { + user.start(); + } + for (User user : users) { + user.join(); + } + for (User user : users) { + user.verify(); + } + } + + static class User extends Thread { + private static final AtomicInteger counter = new AtomicInteger(); + + private final Set sessionIds = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private final Queue responses = new ConcurrentLinkedQueue<>(); + + private final int requests; + + User(int requests) { + super("User" + counter.incrementAndGet()); + this.requests = requests; + } + + @Override + public void run() { + SessionFilter sessions = new SessionFilter(); + for (int i = 0; i < requests; i++) { + Response response = with().filter(sessions).get("/counter"); + if (response.sessionId() != null) { + sessionIds.add(response.sessionId()); + } + responses.add(response.body().asString()); + + try { + // need to sleep longer to give the session store some time to finish + // + // the operation to store session data into Redis is fired off when response headers are written, + // but there's nothing waiting for that operation to complete when the response is being sent + // + // therefore, if we send a 2nd request too quickly after receiving the 1st response, + // the session data may still be in the process of being stored and the 2nd request + // would get stale session data + Thread.sleep(500 + ThreadLocalRandom.current().nextInt(500)); + } catch (InterruptedException e) { + return; + } + } + } + + public void verify() { + assertEquals(1, sessionIds.size()); + String id = sessionIds.iterator().next(); + + assertEquals(requests, responses.size()); + int i = 1; + for (String response : responses) { + assertEquals(id + "|" + i, response); + i++; + } + } + } +} diff --git a/integration-tests/vertx-web/src/main/java/io/quarkus/it/vertx/websessions/CounterEndpoint.java b/integration-tests/vertx-web/src/main/java/io/quarkus/it/vertx/websessions/CounterEndpoint.java new file mode 100644 index 0000000000000..c31b79145ac9b --- /dev/null +++ b/integration-tests/vertx-web/src/main/java/io/quarkus/it/vertx/websessions/CounterEndpoint.java @@ -0,0 +1,22 @@ +package io.quarkus.it.vertx.websessions; + +import io.quarkus.vertx.web.Route; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.Session; + +public class CounterEndpoint { + @Route(path = "/counter", methods = Route.HttpMethod.GET) + String counter(RoutingContext ctx) { + Session session = ctx.session(); + Integer counter = session.get("counter"); + counter = counter == null ? 1 : counter + 1; + session.put("counter", counter); + return session.id() + "|" + counter; + } + + @Route(path = "/check-sessions", methods = Route.HttpMethod.GET) + void checkSessions(RoutingContext ctx) { + Session session = ctx.session(); + ctx.end(session != null ? "OK" : "KO"); + } +} diff --git a/integration-tests/vertx-web/src/main/resources/application.properties b/integration-tests/vertx-web/src/main/resources/application.properties new file mode 100644 index 0000000000000..3f10c491bace2 --- /dev/null +++ b/integration-tests/vertx-web/src/main/resources/application.properties @@ -0,0 +1 @@ +quarkus.http.sessions.mode=in-memory diff --git a/integration-tests/vertx-web/src/test/java/io/quarkus/it/vertx/websessions/CounterTest.java b/integration-tests/vertx-web/src/test/java/io/quarkus/it/vertx/websessions/CounterTest.java new file mode 100644 index 0000000000000..79acd3a66a584 --- /dev/null +++ b/integration-tests/vertx-web/src/test/java/io/quarkus/it/vertx/websessions/CounterTest.java @@ -0,0 +1,89 @@ +package io.quarkus.it.vertx.websessions; + +import static io.restassured.RestAssured.when; +import static io.restassured.RestAssured.with; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicInteger; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.filter.session.SessionFilter; +import io.restassured.response.Response; + +@QuarkusTest +public class CounterTest { + @Test + public void test() throws InterruptedException { + when().get("/check-sessions").then().statusCode(200).body(Matchers.is("OK")); + + List users = new ArrayList<>(); + for (int i = 0; i < 50; i++) { + users.add(new User(100)); + } + + for (User user : users) { + user.start(); + } + for (User user : users) { + user.join(); + } + for (User user : users) { + user.verify(); + } + } + + static class User extends Thread { + private static final AtomicInteger counter = new AtomicInteger(); + + private final Set sessionIds = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private final Queue responses = new ConcurrentLinkedQueue<>(); + + private final int requests; + + User(int requests) { + super("User" + counter.incrementAndGet()); + this.requests = requests; + } + + @Override + public void run() { + SessionFilter sessions = new SessionFilter(); + for (int i = 0; i < requests; i++) { + Response response = with().filter(sessions).get("/counter"); + if (response.sessionId() != null) { + sessionIds.add(response.sessionId()); + } + responses.add(response.body().asString()); + + try { + Thread.sleep(ThreadLocalRandom.current().nextInt(50)); + } catch (InterruptedException e) { + return; + } + } + } + + public void verify() { + assertEquals(1, sessionIds.size()); + String id = sessionIds.iterator().next(); + + assertEquals(requests, responses.size()); + int i = 1; + for (String response : responses) { + assertEquals(id + "|" + i, response); + i++; + } + } + } +}