From 6e04d903ae8a0ea526d7f6f23c57a5b1ca54887f Mon Sep 17 00:00:00 2001 From: Vedran Pavic Date: Thu, 9 Jun 2016 19:13:28 +0200 Subject: [PATCH] Add HazelcastSessionRepository This commit improves existing Hazelcast support, which is based on MapSessionRepository, with dedicated HazelcastSessionRepository that implements the FindByIndexNameSessionRepository contract. Also a new hazelcast-spring-session module was added to provide dependency management for Hazelcast support. Fixes gh-544 --- .../asciidoc/guides/hazelcast-spring.adoc | 14 +- docs/src/docs/asciidoc/index.adoc | 98 ++++--- docs/src/test/java/docs/IndexDocTests.java | 25 ++ .../docs/http/HazelcastHttpSessionConfig.java | 22 +- .../main/java/sample/SecurityInitializer.java | 2 +- .../{Config.java => SessionConfig.java} | 42 ++- settings.gradle | 5 +- spring-session-hazelcast/build.gradle | 19 ++ .../AbstractHazelcastRepositoryITests.java | 12 +- .../HazelcastClientRepositoryITests.java | 4 +- .../hazelcast/HazelcastITestUtils.java | 16 +- .../HazelcastServerRepositoryITests.java | 4 +- .../hazelcast/HazelcastSessionRepository.java | 245 ++++++++++++++++++ .../hazelcast/PrincipalNameExtractor.java | 75 ++++++ .../hazelcast/SessionEntryListener.java | 2 + .../web/http/EnableHazelcastHttpSession.java | 8 +- .../HazelcastHttpSessionConfiguration.java | 127 ++------- .../HazelcastSessionRepositoryTests.java | 232 +++++++++++++++++ ...azelcastHttpSessionConfigurationTests.java | 197 ++++++++++++++ .../hazelcast-custom-idle-time-map-name.xml | 18 +- .../web/http/hazelcast-custom-idle-time.xml | 37 --- .../web/http/hazelcast-custom-map-name.xml | 18 +- 22 files changed, 988 insertions(+), 234 deletions(-) rename samples/hazelcast-spring/src/main/java/sample/{Config.java => SessionConfig.java} (51%) create mode 100644 spring-session-hazelcast/build.gradle create mode 100644 spring-session/src/main/java/org/springframework/session/hazelcast/HazelcastSessionRepository.java create mode 100644 spring-session/src/main/java/org/springframework/session/hazelcast/PrincipalNameExtractor.java create mode 100644 spring-session/src/test/java/org/springframework/session/hazelcast/HazelcastSessionRepositoryTests.java create mode 100644 spring-session/src/test/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfigurationTests.java delete mode 100644 spring-session/src/test/resources/org/springframework/session/hazelcast/config/annotation/web/http/hazelcast-custom-idle-time.xml diff --git a/docs/src/docs/asciidoc/guides/hazelcast-spring.adoc b/docs/src/docs/asciidoc/guides/hazelcast-spring.adoc index 95e304ea..eb4266d4 100644 --- a/docs/src/docs/asciidoc/guides/hazelcast-spring.adoc +++ b/docs/src/docs/asciidoc/guides/hazelcast-spring.adoc @@ -64,6 +64,8 @@ Ensure you have the following in your pom.xml: ---- endif::[] +// tag::config[] + [[security-spring-configuration]] == Spring Configuration @@ -79,7 +81,9 @@ include::{docs-test-dir}docs/http/HazelcastHttpSessionConfig.java[tags=config] <1> The `@EnableHazelcastHttpSession` annotation creates a Spring Bean with the name of `springSessionRepositoryFilter` that implements Filter. The filter is what is in charge of replacing the `HttpSession` implementation to be backed by Spring Session. In this instance Spring Session is backed by Hazelcast. -<2> We create a `HazelcastInstance` that connects Spring Session to Hazelcast. +<2> In order to support retrieval of sessions by principal name index, appropriate `ValueExtractor` needs to be registered. +Spring Session provides `PrincipalNameExtractor` for this purpose. +<3> We create a `HazelcastInstance` that connects Spring Session to Hazelcast. By default, an embedded instance of Hazelcast is started and connected to by the application. For more information on configuring Hazelcast, refer to the http://docs.hazelcast.org/docs/latest/manual/html-single/index.html#hazelcast-configuration[reference documentation]. @@ -88,8 +92,8 @@ For more information on configuring Hazelcast, refer to the http://docs.hazelcas Our <> created a Spring Bean named `springSessionRepositoryFilter` that implements `Filter`. The `springSessionRepositoryFilter` bean is responsible for replacing the `HttpSession` with a custom implementation that is backed by Spring Session. -In order for our `Filter` to do its magic, Spring needs to load our `Config` class. -Since our application is already loading Spring configuration using our `SecurityInitializer` class, we can simply add our Config class to it. +In order for our `Filter` to do its magic, Spring needs to load our `SessionConfig` class. +Since our application is already loading Spring configuration using our `SecurityInitializer` class, we can simply add our `SessionConfig` class to it. .src/main/java/sample/SecurityInitializer.java [source,java] @@ -113,7 +117,7 @@ NOTE: The name of our class (Initializer) does not matter. What is important is By extending `AbstractHttpSessionApplicationInitializer` we ensure that the Spring Bean by the name `springSessionRepositoryFilter` is registered with our Servlet Container for every request before Spring Security's `springSecurityFilterChain`. - +// end::config[] [[hazelcast-spring-security-sample]] == Hazelcast Spring Security Sample Application @@ -188,4 +192,4 @@ For example, you could delete an individual key as follows (replacing `7e8383a4- TIP: The port number of the Hazelcast node will be printed to the console on startup. Replace `xxxxx` above with the port number. -Now observe that you are no longer authenticated with this session. \ No newline at end of file +Now observe that you are no longer authenticated with this session. diff --git a/docs/src/docs/asciidoc/index.adoc b/docs/src/docs/asciidoc/index.adoc index 004a29c3..e0966dbd 100644 --- a/docs/src/docs/asciidoc/index.adoc +++ b/docs/src/docs/asciidoc/index.adoc @@ -368,6 +368,18 @@ There is also a constructor taking `Serializer` and `Deserializer` objects, allo You can create your own session converter by extending `AbstractMongoSessionConverter` class. The implementation will be used for serializing, deserializing your objects and for providing queries to access the session. +[[httpsession-hazelcast]] +=== HttpSession with Hazelcast + +Using Spring Session with `HttpSession` is enabled by adding a Servlet Filter before anything that uses the `HttpSession`. + +This section describes how to use Hazelcast to back `HttpSession` using Java based configuration. + +NOTE: The <> provides a working sample on how to integrate Spring Session and `HttpSession` using Java configuration. +You can read the basic steps for integration below, but you are encouraged to follow along with the detailed Hazelcast Spring Guide when integrating with your own application. + +include::guides/hazelcast-spring.adoc[tags=config,leveloffset=+2] + [[httpsession-how]] === How HttpSession Integration Works @@ -647,44 +659,6 @@ It is important to note that no infrastructure for session expirations is config This is because things like session expiration are highly implementation dependent. This means if you require cleaning up expired sessions, you are responsible for cleaning up the expired sessions. -[[api-enablehazelcasthttpsession]] -=== EnableHazelcastHttpSession - -If you wish to use http://hazelcast.org/[Hazelcast] as your backing source for the `SessionRepository`, then the `@EnableHazelcastHttpSession` annotation -can be added to an `@Configuration` class. This extends the functionality provided by the `@EnableSpringHttpSession` annotation but makes the `SessionRepository` for you in Hazelcast. -You must provide a single `HazelcastInstance` bean for the configuration to work. -For example: - -[source,java,indent=0] ----- -include::{docs-test-dir}docs/http/HazelcastHttpSessionConfig.java[tags=config] ----- - -This will configure Hazelcast in embedded mode with default configuration. -See the http://docs.hazelcast.org/docs/latest/manual/html-single/index.html#hazelcast-configuration[Hazelcast documentation] for -detailed information on configuration options for Hazelcast. - -[[api-enablehazelcasthttpsession-storage]] -==== Storage Details - -Sessions will be stored in a distributed `Map` in Hazelcast using a <>. -The `Map` interface methods will be used to `get()` and `put()` Sessions. -The expiration of a session in the `Map` is handled by Hazelcast's support for setting the time to live on an entry when it is `put()` into the `Map`. Entries (sessions) that have been idle longer than the time to live will be automatically removed from the `Map`. - -You shouldn't need to configure any settings such as `max-idle-seconds` or `time-to-live-seconds` for the `Map` within the Hazelcast configuration. - -[[api-enablehazelcasthttpsession-customize]] -==== Basic Customization -You can use the following attributes on `@EnableHazelcastHttpSession` to customize the configuration: - -* **maxInactiveIntervalInSeconds** - the amount of time before the session will expire in seconds. Default is 1800 seconds (30 minutes) -* **sessionMapName** - the name of the distributed `Map` that will be used in Hazelcast to store the session data. - -[[api-enablehazelcasthttpsession-events]] -==== Session Events -Using a `MapListener` to respond to entries being added, evicted, and removed from the distributed `Map`, these events will trigger -publishing SessionCreatedEvent, SessionExpiredEvent, and SessionDeletedEvent events respectively using the `ApplicationEventPublisher`. - [[api-redisoperationssessionrepository]] === RedisOperationsSessionRepository @@ -1104,6 +1078,54 @@ include::{session-main-resources-dir}org/springframework/session/jdbc/schema-mys All JDBC operations in `JdbcOperationsSessionRepository` are executed in a transactional manner. Transactions are executed with propagation set to `REQUIRES_NEW` in order to avoid unexpected behavior due to interference with existing transactions (for example, executing `save` operation in a thread that already participates in a read-only transaction). +[[api-hazelcastsessionrepository]] +=== HazelcastSessionRepository + +`HazelcastSessionRepository` is a `SessionRepository` implementation that stores sessions in Hazelcast's distributed `IMap`. +In a web environment, this is typically used in combination with `SessionRepositoryFilter`. + +[[api-hazelcastsessionrepository-new]] +==== Instantiating a HazelcastSessionRepository + +A typical example of how to create a new instance can be seen below: + +[source,java,indent=0] +---- +include::{indexdoc-tests}[tags=new-hazelcastsessionrepository] +---- + +For additional information on how to create and configure Hazelcast instance, refer to the http://docs.hazelcast.org/docs/latest/manual/html-single/index.html#hazelcast-configuration[Hazelcast documentation]. + +[[api-enablehazelcasthttpsession]] +==== EnableHazelcastHttpSession + +If you wish to use http://hazelcast.org/[Hazelcast] as your backing source for the `SessionRepository`, then the `@EnableHazelcastHttpSession` annotation +can be added to an `@Configuration` class. This extends the functionality provided by the `@EnableSpringHttpSession` annotation but makes the `SessionRepository` for you in Hazelcast. +You must provide a single `HazelcastInstance` bean for the configuration to work. +Complete configuration example can be found in the <> + +[[api-enablehazelcasthttpsession-storage]] +==== Storage Details + +Sessions will be stored in a distributed `IMap` in Hazelcast using a <>. +The `IMap` interface methods will be used to `get()` and `put()` Sessions. +Additionally, `values()` method is used to support `FindByIndexNameSessionRepository#findByIndexNameAndIndexValue` operation, together with appropriate `ValueExtractor` that needs to be registered with Hazelcast. Refer to <> for more details on this configuration. +The expiration of a session in the `IMap` is handled by Hazelcast's support for setting the time to live on an entry when it is `put()` into the `IMap`. Entries (sessions) that have been idle longer than the time to live will be automatically removed from the `IMap`. + +You shouldn't need to configure any settings such as `max-idle-seconds` or `time-to-live-seconds` for the `IMap` within the Hazelcast configuration. + +[[api-enablehazelcasthttpsession-customize]] +==== Basic Customization +You can use the following attributes on `@EnableHazelcastHttpSession` to customize the configuration: + +* **maxInactiveIntervalInSeconds** - the amount of time before the session will expire in seconds. Default is 1800 seconds (30 minutes) +* **sessionMapName** - the name of the distributed `Map` that will be used in Hazelcast to store the session data. + +[[api-enablehazelcasthttpsession-events]] +==== Session Events +Using a `MapListener` to respond to entries being added, evicted, and removed from the distributed `Map`, these events will trigger +publishing SessionCreatedEvent, SessionExpiredEvent, and SessionDeletedEvent events respectively using the `ApplicationEventPublisher`. + [[community]] == Spring Session Community diff --git a/docs/src/test/java/docs/IndexDocTests.java b/docs/src/test/java/docs/IndexDocTests.java index de213889..389d5251 100644 --- a/docs/src/test/java/docs/IndexDocTests.java +++ b/docs/src/test/java/docs/IndexDocTests.java @@ -16,6 +16,10 @@ package docs; +import com.hazelcast.config.Config; +import com.hazelcast.core.Hazelcast; +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.core.IMap; import org.junit.Test; import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; @@ -23,10 +27,12 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.mock.web.MockServletContext; import org.springframework.session.ExpiringSession; +import org.springframework.session.MapSession; import org.springframework.session.MapSessionRepository; import org.springframework.session.Session; import org.springframework.session.SessionRepository; import org.springframework.session.data.redis.RedisOperationsSessionRepository; +import org.springframework.session.hazelcast.HazelcastSessionRepository; import org.springframework.session.jdbc.JdbcOperationsSessionRepository; import org.springframework.session.web.http.SessionRepositoryFilter; import org.springframework.transaction.PlatformTransactionManager; @@ -135,6 +141,25 @@ public class IndexDocTests { // end::new-jdbcoperationssessionrepository[] } + @Test + @SuppressWarnings("unused") + public void newHazelcastSessionRepository() { + // tag::new-hazelcastsessionrepository[] + + Config config = new Config(); + + // ... configure Hazelcast ... + + HazelcastInstance hazelcastInstance = Hazelcast.newHazelcastInstance(config); + + IMap sessions = hazelcastInstance + .getMap("spring:session:sessions"); + + HazelcastSessionRepository repository = + new HazelcastSessionRepository(sessions); + // end::new-hazelcastsessionrepository[] + } + @Test public void runSpringHttpSessionConfig() { AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); diff --git a/docs/src/test/java/docs/http/HazelcastHttpSessionConfig.java b/docs/src/test/java/docs/http/HazelcastHttpSessionConfig.java index 6a472095..2075bd6d 100644 --- a/docs/src/test/java/docs/http/HazelcastHttpSessionConfig.java +++ b/docs/src/test/java/docs/http/HazelcastHttpSessionConfig.java @@ -17,21 +17,37 @@ package docs.http; import com.hazelcast.config.Config; +import com.hazelcast.config.MapAttributeConfig; +import com.hazelcast.config.MapIndexConfig; import com.hazelcast.core.Hazelcast; import com.hazelcast.core.HazelcastInstance; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.session.hazelcast.HazelcastSessionRepository; +import org.springframework.session.hazelcast.PrincipalNameExtractor; import org.springframework.session.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession; //tag::config[] @EnableHazelcastHttpSession // <1> @Configuration public class HazelcastHttpSessionConfig { + @Bean - public HazelcastInstance embeddedHazelcast() { - Config hazelcastConfig = new Config(); - return Hazelcast.newHazelcastInstance(hazelcastConfig); // <2> + public HazelcastInstance hazelcastInstance() { + MapAttributeConfig attributeConfig = new MapAttributeConfig() + .setName(HazelcastSessionRepository.PRINCIPAL_NAME_ATTRIBUTE) + .setExtractor(PrincipalNameExtractor.class.getName()); + + Config config = new Config(); + + config.getMapConfig("spring:session:sessions") // <2> + .addMapAttributeConfig(attributeConfig) + .addMapIndexConfig(new MapIndexConfig( + HazelcastSessionRepository.PRINCIPAL_NAME_ATTRIBUTE, false)); + + return Hazelcast.newHazelcastInstance(config); // <3> } + } // end::config[] diff --git a/samples/hazelcast-spring/src/main/java/sample/SecurityInitializer.java b/samples/hazelcast-spring/src/main/java/sample/SecurityInitializer.java index 76e3c515..36f86118 100644 --- a/samples/hazelcast-spring/src/main/java/sample/SecurityInitializer.java +++ b/samples/hazelcast-spring/src/main/java/sample/SecurityInitializer.java @@ -22,7 +22,7 @@ import org.springframework.security.web.context.AbstractSecurityWebApplicationIn public class SecurityInitializer extends AbstractSecurityWebApplicationInitializer { public SecurityInitializer() { - super(SecurityConfig.class, Config.class); + super(SecurityConfig.class, SessionConfig.class); } } // end::class[] diff --git a/samples/hazelcast-spring/src/main/java/sample/Config.java b/samples/hazelcast-spring/src/main/java/sample/SessionConfig.java similarity index 51% rename from samples/hazelcast-spring/src/main/java/sample/Config.java rename to samples/hazelcast-spring/src/main/java/sample/SessionConfig.java index a5b2eca2..1451d09f 100644 --- a/samples/hazelcast-spring/src/main/java/sample/Config.java +++ b/samples/hazelcast-spring/src/main/java/sample/SessionConfig.java @@ -16,33 +16,53 @@ package sample; -import com.hazelcast.config.NetworkConfig; +import com.hazelcast.config.Config; +import com.hazelcast.config.MapAttributeConfig; +import com.hazelcast.config.MapIndexConfig; import com.hazelcast.config.SerializerConfig; import com.hazelcast.core.Hazelcast; import com.hazelcast.core.HazelcastInstance; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.session.hazelcast.HazelcastSessionRepository; +import org.springframework.session.hazelcast.PrincipalNameExtractor; import org.springframework.session.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession; import org.springframework.util.SocketUtils; // tag::class[] @EnableHazelcastHttpSession(maxInactiveIntervalInSeconds = 300) @Configuration -public class Config { +public class SessionConfig { @Bean(destroyMethod = "shutdown") public HazelcastInstance hazelcastInstance() { - com.hazelcast.config.Config cfg = new com.hazelcast.config.Config(); - NetworkConfig netConfig = new NetworkConfig(); - netConfig.setPort(SocketUtils.findAvailableTcpPort()); - System.out.println("Hazelcast port #: " + netConfig.getPort()); - cfg.setNetworkConfig(netConfig); - SerializerConfig serializer = new SerializerConfig().setTypeClass(Object.class) - .setImplementation(new ObjectStreamSerializer()); - cfg.getSerializationConfig().addSerializerConfig(serializer); + Config config = new Config(); - return Hazelcast.newHazelcastInstance(cfg); + int port = SocketUtils.findAvailableTcpPort(); + + config.getNetworkConfig() + .setPort(port); + + System.out.println("Hazelcast port #: " + port); + + SerializerConfig serializer = new SerializerConfig() + .setImplementation(new ObjectStreamSerializer()) + .setTypeClass(Object.class); + + config.getSerializationConfig() + .addSerializerConfig(serializer); + + MapAttributeConfig attributeConfig = new MapAttributeConfig() + .setName(HazelcastSessionRepository.PRINCIPAL_NAME_ATTRIBUTE) + .setExtractor(PrincipalNameExtractor.class.getName()); + + config.getMapConfig("spring:session:sessions") + .addMapAttributeConfig(attributeConfig) + .addMapIndexConfig(new MapIndexConfig( + HazelcastSessionRepository.PRINCIPAL_NAME_ATTRIBUTE, false)); + + return Hazelcast.newHazelcastInstance(config); } } diff --git a/settings.gradle b/settings.gradle index 31fb8ebe..5cd65865 100644 --- a/settings.gradle +++ b/settings.gradle @@ -28,6 +28,7 @@ include 'samples:grails3' include 'spring-session' include 'spring-session-data-gemfire' include 'spring-session-data-geode' -include 'spring-session-data-redis' -include 'spring-session-jdbc' include 'spring-session-data-mongo' +include 'spring-session-data-redis' +include 'spring-session-hazelcast' +include 'spring-session-jdbc' diff --git a/spring-session-hazelcast/build.gradle b/spring-session-hazelcast/build.gradle new file mode 100644 index 00000000..3dc3ff37 --- /dev/null +++ b/spring-session-hazelcast/build.gradle @@ -0,0 +1,19 @@ +apply from: JAVA_GRADLE +apply from: MAVEN_GRADLE + +apply plugin: 'spring-io' + +description = "Aggregator for Spring Session and Hazelcast" + +dependencies { + compile project(':spring-session'), + "com.hazelcast:hazelcast:$hazelcastVersion" +} + +dependencyManagement { + springIoTestRuntime { + imports { + mavenBom "io.spring.platform:platform-bom:${springIoVersion}" + } + } +} diff --git a/spring-session/src/integration-test/java/org/springframework/session/hazelcast/AbstractHazelcastRepositoryITests.java b/spring-session/src/integration-test/java/org/springframework/session/hazelcast/AbstractHazelcastRepositoryITests.java index 10aa0ddf..d5fba1fe 100644 --- a/spring-session/src/integration-test/java/org/springframework/session/hazelcast/AbstractHazelcastRepositoryITests.java +++ b/spring-session/src/integration-test/java/org/springframework/session/hazelcast/AbstractHazelcastRepositoryITests.java @@ -21,8 +21,7 @@ import com.hazelcast.core.IMap; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.session.ExpiringSession; -import org.springframework.session.SessionRepository; +import org.springframework.session.MapSession; import static org.assertj.core.api.Assertions.assertThat; @@ -32,20 +31,21 @@ import static org.assertj.core.api.Assertions.assertThat; * @author Tommy Ludwig * @author Vedran Pavic */ -public abstract class AbstractHazelcastRepositoryITests { +public abstract class AbstractHazelcastRepositoryITests { @Autowired private HazelcastInstance hazelcast; @Autowired - private SessionRepository repository; + private HazelcastSessionRepository repository; @Test public void createAndDestroySession() { - S sessionToSave = this.repository.createSession(); + MapSession sessionToSave = this.repository.createSession(); String sessionId = sessionToSave.getId(); - IMap hazelcastMap = this.hazelcast.getMap("spring:session:sessions"); + IMap hazelcastMap = this.hazelcast.getMap( + "spring:session:sessions"); assertThat(hazelcastMap.size()).isEqualTo(0); diff --git a/spring-session/src/integration-test/java/org/springframework/session/hazelcast/HazelcastClientRepositoryITests.java b/spring-session/src/integration-test/java/org/springframework/session/hazelcast/HazelcastClientRepositoryITests.java index ba1ce4c6..7dab0184 100644 --- a/spring-session/src/integration-test/java/org/springframework/session/hazelcast/HazelcastClientRepositoryITests.java +++ b/spring-session/src/integration-test/java/org/springframework/session/hazelcast/HazelcastClientRepositoryITests.java @@ -25,7 +25,6 @@ import org.junit.runner.RunWith; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.session.ExpiringSession; import org.springframework.session.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; @@ -43,8 +42,7 @@ import org.springframework.util.SocketUtils; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration @WebAppConfiguration -public class HazelcastClientRepositoryITests - extends AbstractHazelcastRepositoryITests { +public class HazelcastClientRepositoryITests extends AbstractHazelcastRepositoryITests { private static final int PORT = SocketUtils.findAvailableTcpPort(); diff --git a/spring-session/src/integration-test/java/org/springframework/session/hazelcast/HazelcastITestUtils.java b/spring-session/src/integration-test/java/org/springframework/session/hazelcast/HazelcastITestUtils.java index 1aa5eb58..119232cd 100644 --- a/spring-session/src/integration-test/java/org/springframework/session/hazelcast/HazelcastITestUtils.java +++ b/spring-session/src/integration-test/java/org/springframework/session/hazelcast/HazelcastITestUtils.java @@ -17,6 +17,8 @@ package org.springframework.session.hazelcast; import com.hazelcast.config.Config; +import com.hazelcast.config.MapAttributeConfig; +import com.hazelcast.config.MapIndexConfig; import com.hazelcast.core.Hazelcast; import com.hazelcast.core.HazelcastInstance; @@ -38,8 +40,20 @@ public final class HazelcastITestUtils { * @return the Hazelcast instance */ public static HazelcastInstance embeddedHazelcastServer(int port) { + MapAttributeConfig attributeConfig = new MapAttributeConfig() + .setName(HazelcastSessionRepository.PRINCIPAL_NAME_ATTRIBUTE) + .setExtractor(PrincipalNameExtractor.class.getName()); + Config config = new Config(); - config.getNetworkConfig().setPort(port); + + config.getNetworkConfig() + .setPort(port); + + config.getMapConfig("spring:session:sessions") + .addMapAttributeConfig(attributeConfig) + .addMapIndexConfig(new MapIndexConfig( + HazelcastSessionRepository.PRINCIPAL_NAME_ATTRIBUTE, false)); + return Hazelcast.newHazelcastInstance(config); } diff --git a/spring-session/src/integration-test/java/org/springframework/session/hazelcast/HazelcastServerRepositoryITests.java b/spring-session/src/integration-test/java/org/springframework/session/hazelcast/HazelcastServerRepositoryITests.java index b59346a3..3749f89f 100644 --- a/spring-session/src/integration-test/java/org/springframework/session/hazelcast/HazelcastServerRepositoryITests.java +++ b/spring-session/src/integration-test/java/org/springframework/session/hazelcast/HazelcastServerRepositoryITests.java @@ -21,7 +21,6 @@ import org.junit.runner.RunWith; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.session.ExpiringSession; import org.springframework.session.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; @@ -37,8 +36,7 @@ import org.springframework.test.context.web.WebAppConfiguration; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration @WebAppConfiguration -public class HazelcastServerRepositoryITests - extends AbstractHazelcastRepositoryITests { +public class HazelcastServerRepositoryITests extends AbstractHazelcastRepositoryITests { @EnableHazelcastHttpSession @Configuration diff --git a/spring-session/src/main/java/org/springframework/session/hazelcast/HazelcastSessionRepository.java b/spring-session/src/main/java/org/springframework/session/hazelcast/HazelcastSessionRepository.java new file mode 100644 index 00000000..7f820664 --- /dev/null +++ b/spring-session/src/main/java/org/springframework/session/hazelcast/HazelcastSessionRepository.java @@ -0,0 +1,245 @@ +/* + * Copyright 2014-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.session.hazelcast; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; + +import com.hazelcast.core.EntryEvent; +import com.hazelcast.core.IMap; +import com.hazelcast.map.listener.EntryAddedListener; +import com.hazelcast.map.listener.EntryEvictedListener; +import com.hazelcast.map.listener.EntryRemovedListener; +import com.hazelcast.query.Predicates; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.MapSession; +import org.springframework.session.events.AbstractSessionEvent; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.session.events.SessionDeletedEvent; +import org.springframework.session.events.SessionExpiredEvent; +import org.springframework.util.Assert; + +/** + * A {@link org.springframework.session.SessionRepository} implementation that stores + * sessions in Hazelcast's distributed {@link IMap}. + * + *

+ * An example of how to create a new instance can be seen below: + * + *

+ * Config config = new Config();
+ *
+ * // ... configure Hazelcast ...
+ *
+ * HazelcastInstance hazelcastInstance = Hazelcast.newHazelcastInstance(config);
+ *
+ * IMap{@code } sessions = hazelcastInstance
+ *         .getMap("spring:session:sessions");
+ *
+ * HazelcastSessionRepository sessionRepository =
+ *         new HazelcastSessionRepository(sessions);
+ * 
+ * + * In order to support finding sessions by principal name using + * {@link #findByIndexNameAndIndexValue(String, String)} method, custom configuration of + * {@code IMap} supplied to this implementation is required. + * + * The following snippet demonstrates how to define required configuration using + * programmatic Hazelcast Configuration: + * + *
+ * MapAttributeConfig attributeConfig = new MapAttributeConfig()
+ *         .setName(HazelcastSessionRepository.PRINCIPAL_NAME_ATTRIBUTE)
+ *         .setExtractor(PrincipalNameExtractor.class.getName());
+ *
+ * Config config = new Config();
+ *
+ * config.getMapConfig("spring:session:sessions")
+ *         .addMapAttributeConfig(attributeConfig)
+ *         .addMapIndexConfig(new MapIndexConfig(
+ *                 HazelcastSessionRepository.PRINCIPAL_NAME_ATTRIBUTE, false));
+ *
+ * Hazelcast.newHazelcastInstance(config);
+ * 
+ * + * This implementation listens for events on the Hazelcast-backed SessionRepository and + * translates those events into the corresponding Spring Session events. Publish the + * Spring Session events with the given {@link ApplicationEventPublisher}. + * + *
    + *
  • entryAdded - {@link SessionCreatedEvent}
  • + *
  • entryEvicted - {@link SessionExpiredEvent}
  • + *
  • entryRemoved - {@link SessionDeletedEvent}
  • + *
+ * + * @author Vedran Pavic + * @author Tommy Ludwig + * @author Mark Anderson + * @since 1.3.0 + */ +public class HazelcastSessionRepository implements + FindByIndexNameSessionRepository, + EntryAddedListener, + EntryEvictedListener, + EntryRemovedListener { + + /** + * The principal name custom attribute name. + */ + public static final String PRINCIPAL_NAME_ATTRIBUTE = "principalName"; + + private static final Log logger = LogFactory.getLog(HazelcastSessionRepository.class); + + private final IMap sessions; + + private ApplicationEventPublisher eventPublisher = new ApplicationEventPublisher() { + + public void publishEvent(ApplicationEvent event) { + } + + public void publishEvent(Object event) { + } + + }; + + /** + * If non-null, this value is used to override + * {@link MapSession#setMaxInactiveIntervalInSeconds(int)}. + */ + private Integer defaultMaxInactiveInterval; + + private String sessionListenerId; + + public HazelcastSessionRepository(IMap sessions) { + Assert.notNull(sessions, "Sessions IMap must not be null"); + this.sessions = sessions; + } + + @PostConstruct + private void init() { + this.sessionListenerId = this.sessions.addEntryListener(this, true); + } + + @PreDestroy + private void close() { + this.sessions.removeEntryListener(this.sessionListenerId); + } + + /** + * Sets the {@link ApplicationEventPublisher} that is used to publish + * {@link AbstractSessionEvent session events}. The default is to not publish session + * events. + * + * @param applicationEventPublisher the {@link ApplicationEventPublisher} that is used + * to publish session events. Cannot be null. + */ + public void setApplicationEventPublisher( + ApplicationEventPublisher applicationEventPublisher) { + Assert.notNull(applicationEventPublisher, + "ApplicationEventPublisher cannot be null"); + this.eventPublisher = applicationEventPublisher; + } + + /** + * Set the maximum inactive interval in seconds between requests before newly created + * sessions will be invalidated. A negative time indicates that the session will never + * timeout. The default is 1800 (30 minutes). + * @param defaultMaxInactiveInterval the maximum inactive interval in seconds + */ + public void setDefaultMaxInactiveInterval(Integer defaultMaxInactiveInterval) { + this.defaultMaxInactiveInterval = defaultMaxInactiveInterval; + } + + public MapSession createSession() { + MapSession result = new MapSession(); + if (this.defaultMaxInactiveInterval != null) { + result.setMaxInactiveIntervalInSeconds(this.defaultMaxInactiveInterval); + } + return result; + } + + public void save(MapSession session) { + this.sessions.put(session.getId(), session, + session.getMaxInactiveIntervalInSeconds(), TimeUnit.SECONDS); + } + + public MapSession getSession(String id) { + MapSession saved = this.sessions.get(id); + if (saved == null) { + return null; + } + if (saved.isExpired()) { + delete(saved.getId()); + return null; + } + return saved; + } + + public void delete(String id) { + this.sessions.remove(id); + } + + public Map findByIndexNameAndIndexValue( + String indexName, String indexValue) { + if (!PRINCIPAL_NAME_INDEX_NAME.equals(indexName)) { + return Collections.emptyMap(); + } + Collection sessions = this.sessions.values( + Predicates.equal(PRINCIPAL_NAME_ATTRIBUTE, indexValue)); + Map sessionMap = new HashMap( + sessions.size()); + for (MapSession session : sessions) { + sessionMap.put(session.getId(), session); + } + return sessionMap; + } + + public void entryAdded(EntryEvent event) { + if (logger.isDebugEnabled()) { + logger.debug("Session created with id: " + event.getValue().getId()); + } + this.eventPublisher.publishEvent(new SessionCreatedEvent(this, event.getValue())); + } + + public void entryEvicted(EntryEvent event) { + if (logger.isDebugEnabled()) { + logger.debug("Session expired with id: " + event.getOldValue().getId()); + } + this.eventPublisher + .publishEvent(new SessionExpiredEvent(this, event.getOldValue())); + } + + public void entryRemoved(EntryEvent event) { + if (logger.isDebugEnabled()) { + logger.debug("Session deleted with id: " + event.getOldValue().getId()); + } + this.eventPublisher + .publishEvent(new SessionDeletedEvent(this, event.getOldValue())); + } + +} diff --git a/spring-session/src/main/java/org/springframework/session/hazelcast/PrincipalNameExtractor.java b/spring-session/src/main/java/org/springframework/session/hazelcast/PrincipalNameExtractor.java new file mode 100644 index 00000000..d63966b3 --- /dev/null +++ b/spring-session/src/main/java/org/springframework/session/hazelcast/PrincipalNameExtractor.java @@ -0,0 +1,75 @@ +/* + * Copyright 2014-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.session.hazelcast; + +import com.hazelcast.query.extractor.ValueCollector; +import com.hazelcast.query.extractor.ValueExtractor; + +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.MapSession; +import org.springframework.session.Session; + +/** + * Hazelcast {@link ValueExtractor} responsible for extracting principal name from the + * {@link MapSession}. + * + * @author Vedran Pavic + * @since 1.3.0 + */ +public class PrincipalNameExtractor extends ValueExtractor { + + private static final PrincipalNameResolver PRINCIPAL_NAME_RESOLVER = + new PrincipalNameResolver(); + + @SuppressWarnings("unchecked") + public void extract(MapSession target, String argument, + ValueCollector collector) { + String principalName = PRINCIPAL_NAME_RESOLVER.resolvePrincipal(target); + if (principalName != null) { + collector.addObject(principalName); + } + } + + /** + * Resolves the Spring Security principal name. + */ + static class PrincipalNameResolver { + + private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; + + private SpelExpressionParser parser = new SpelExpressionParser(); + + public String resolvePrincipal(Session session) { + String principalName = session.getAttribute( + FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME); + if (principalName != null) { + return principalName; + } + Object authentication = session.getAttribute(SPRING_SECURITY_CONTEXT); + if (authentication != null) { + Expression expression = this.parser + .parseExpression("authentication?.name"); + return expression.getValue(authentication, String.class); + } + return null; + } + + } + +} diff --git a/spring-session/src/main/java/org/springframework/session/hazelcast/SessionEntryListener.java b/spring-session/src/main/java/org/springframework/session/hazelcast/SessionEntryListener.java index 10099df7..63fb91c1 100644 --- a/spring-session/src/main/java/org/springframework/session/hazelcast/SessionEntryListener.java +++ b/spring-session/src/main/java/org/springframework/session/hazelcast/SessionEntryListener.java @@ -43,7 +43,9 @@ import org.springframework.util.Assert; * @author Tommy Ludwig * @author Mark Anderson * @since 1.1 + * @deprecated Use {@link HazelcastSessionRepository} instead. */ +@Deprecated public class SessionEntryListener implements EntryAddedListener, EntryEvictedListener, EntryRemovedListener { diff --git a/spring-session/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/EnableHazelcastHttpSession.java b/spring-session/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/EnableHazelcastHttpSession.java index 6106bfea..702b815a 100644 --- a/spring-session/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/EnableHazelcastHttpSession.java +++ b/spring-session/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/EnableHazelcastHttpSession.java @@ -22,6 +22,7 @@ import java.lang.annotation.Target; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.session.MapSession; import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession; /** @@ -56,19 +57,20 @@ import org.springframework.session.config.annotation.web.http.EnableSpringHttpSe @Import(HazelcastHttpSessionConfiguration.class) @Configuration public @interface EnableHazelcastHttpSession { + /** * This is the session timeout in seconds. By default, it is set to 1800 seconds (30 * minutes). This should be a non-negative integer. * * @return the seconds a session can be inactive before expiring */ - int maxInactiveIntervalInSeconds() default 1800; + int maxInactiveIntervalInSeconds() default MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS; /** * This is the name of the Map that will be used in Hazelcast to store the session - * data. Default is "spring:session:sessions". + * data. Default is {@link HazelcastHttpSessionConfiguration#DEFAULT_SESSION_MAP_NAME}. * @return the name of the Map to store the sessions in Hazelcast */ - String sessionMapName() default "spring:session:sessions"; + String sessionMapName() default HazelcastHttpSessionConfiguration.DEFAULT_SESSION_MAP_NAME; } diff --git a/spring-session/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfiguration.java b/spring-session/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfiguration.java index ca0ea40e..b0082041 100644 --- a/spring-session/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfiguration.java +++ b/spring-session/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfiguration.java @@ -16,12 +16,7 @@ package org.springframework.session.hazelcast.config.annotation.web.http; -import java.util.Collection; import java.util.Map; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -import javax.annotation.PreDestroy; import com.hazelcast.core.HazelcastInstance; import com.hazelcast.core.IMap; @@ -32,11 +27,9 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ImportAware; import org.springframework.core.annotation.AnnotationAttributes; import org.springframework.core.type.AnnotationMetadata; -import org.springframework.session.ExpiringSession; -import org.springframework.session.MapSessionRepository; -import org.springframework.session.SessionRepository; +import org.springframework.session.MapSession; import org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration; -import org.springframework.session.hazelcast.SessionEntryListener; +import org.springframework.session.hazelcast.HazelcastSessionRepository; import org.springframework.session.web.http.SessionRepositoryFilter; /** @@ -45,6 +38,7 @@ import org.springframework.session.web.http.SessionRepositoryFilter; * {@link HazelcastInstance} must be exposed as a Bean. * * @author Tommy Ludwig + * @author Vedran Pavic * @since 1.1 * @see EnableHazelcastHttpSession */ @@ -52,49 +46,30 @@ import org.springframework.session.web.http.SessionRepositoryFilter; public class HazelcastHttpSessionConfiguration extends SpringHttpSessionConfiguration implements ImportAware { - private Integer maxInactiveIntervalInSeconds = 1800; + static final String DEFAULT_SESSION_MAP_NAME = "spring:session:sessions"; - private String sessionMapName = "spring:session:sessions"; + private Integer maxInactiveIntervalInSeconds; - private String sessionListenerUid; - - private IMap sessionsMap; + private String sessionMapName = DEFAULT_SESSION_MAP_NAME; @Bean - public SessionRepository sessionRepository( - HazelcastInstance hazelcastInstance, SessionEntryListener sessionListener) { - this.sessionsMap = hazelcastInstance.getMap(this.sessionMapName); - this.sessionListenerUid = this.sessionsMap.addEntryListener(sessionListener, - true); - - MapSessionRepository sessionRepository = new MapSessionRepository( - new ExpiringSessionMap(this.sessionsMap)); - sessionRepository - .setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds); - - return sessionRepository; - } - - @PreDestroy - private void removeSessionListener() { - this.sessionsMap.removeEntryListener(this.sessionListenerUid); - } - - @Bean - public SessionEntryListener sessionListener( + public HazelcastSessionRepository sessionRepository( + HazelcastInstance hazelcastInstance, ApplicationEventPublisher eventPublisher) { - return new SessionEntryListener(eventPublisher); + IMap sessions = hazelcastInstance.getMap( + this.sessionMapName); + HazelcastSessionRepository sessionRepository = new HazelcastSessionRepository( + sessions); + sessionRepository.setApplicationEventPublisher(eventPublisher); + sessionRepository.setDefaultMaxInactiveInterval( + this.maxInactiveIntervalInSeconds); + return sessionRepository; } public void setImportMetadata(AnnotationMetadata importMetadata) { Map enableAttrMap = importMetadata .getAnnotationAttributes(EnableHazelcastHttpSession.class.getName()); AnnotationAttributes enableAttrs = AnnotationAttributes.fromMap(enableAttrMap); - - transferAnnotationAttributes(enableAttrs); - } - - private void transferAnnotationAttributes(AnnotationAttributes enableAttrs) { setMaxInactiveIntervalInSeconds( (Integer) enableAttrs.getNumber("maxInactiveIntervalInSeconds")); setSessionMapName(enableAttrs.getString("sessionMapName")); @@ -108,74 +83,4 @@ public class HazelcastHttpSessionConfiguration extends SpringHttpSessionConfigur this.sessionMapName = sessionMapName; } - /** - * A wrapper for Hazelcast's {@link IMap} which is used to store the sessions. - */ - static class ExpiringSessionMap implements Map { - private IMap delegate; - - ExpiringSessionMap(IMap delegate) { - this.delegate = delegate; - } - - public ExpiringSession put(String key, ExpiringSession value) { - if (value == null) { - return this.delegate.put(key, value); - } - return this.delegate.put(key, value, value.getMaxInactiveIntervalInSeconds(), - TimeUnit.SECONDS); - } - - public int size() { - return this.delegate.size(); - } - - public boolean isEmpty() { - return this.delegate.isEmpty(); - } - - public boolean containsKey(Object key) { - return this.delegate.containsKey(key); - } - - public boolean containsValue(Object value) { - return this.delegate.containsValue(value); - } - - public ExpiringSession get(Object key) { - return this.delegate.get(key); - } - - public ExpiringSession remove(Object key) { - return this.delegate.remove(key); - } - - public void putAll(Map m) { - this.delegate.putAll(m); - } - - public void clear() { - this.delegate.clear(); - } - - public Set keySet() { - return this.delegate.keySet(); - } - - public Collection values() { - return this.delegate.values(); - } - - public Set> entrySet() { - return this.delegate.entrySet(); - } - - public boolean equals(Object o) { - return this.delegate.equals(o); - } - - public int hashCode() { - return this.delegate.hashCode(); - } - } } diff --git a/spring-session/src/test/java/org/springframework/session/hazelcast/HazelcastSessionRepositoryTests.java b/spring-session/src/test/java/org/springframework/session/hazelcast/HazelcastSessionRepositoryTests.java new file mode 100644 index 00000000..fa69ee92 --- /dev/null +++ b/spring-session/src/test/java/org/springframework/session/hazelcast/HazelcastSessionRepositoryTests.java @@ -0,0 +1,232 @@ +/* + * Copyright 2014-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.session.hazelcast; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import com.hazelcast.core.IMap; +import com.hazelcast.query.impl.predicates.EqualPredicate; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.MapSession; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.isA; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; + +/** + * Tests for {@link HazelcastSessionRepository}. + * + * @author Vedran Pavic + */ +@RunWith(MockitoJUnitRunner.class) +public class HazelcastSessionRepositoryTests { + + private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Mock + private IMap sessions; + + private HazelcastSessionRepository repository; + + @Before + public void setUp() { + this.repository = new HazelcastSessionRepository(this.sessions); + } + + @Test + public void constructorNullHazelcastInstance() { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Sessions IMap must not be null"); + + new HazelcastSessionRepository(null); + } + + @Test + public void createSessionDefaultMaxInactiveInterval() throws Exception { + MapSession session = this.repository.createSession(); + + assertThat(session.getMaxInactiveIntervalInSeconds()) + .isEqualTo(new MapSession().getMaxInactiveIntervalInSeconds()); + verifyZeroInteractions(this.sessions); + } + + @Test + public void createSessionCustomMaxInactiveInterval() throws Exception { + int interval = 1; + this.repository.setDefaultMaxInactiveInterval(interval); + + MapSession session = this.repository.createSession(); + + assertThat(session.getMaxInactiveIntervalInSeconds()).isEqualTo(interval); + verifyZeroInteractions(this.sessions); + } + + @Test + public void saveNew() { + MapSession session = this.repository.createSession(); + + this.repository.save(session); + + verify(this.sessions, times(1)).put(eq(session.getId()), eq(session), + isA(Long.class), eq(TimeUnit.SECONDS)); + } + + @Test + public void saveUpdatedAttributes() { + MapSession session = new MapSession(); + session.setAttribute("testName", "testValue"); + + this.repository.save(session); + + verify(this.sessions, times(1)).put(eq(session.getId()), eq(session), + isA(Long.class), eq(TimeUnit.SECONDS)); + } + + @Test + public void saveUpdatedLastAccessedTime() { + MapSession session = new MapSession(); + session.setLastAccessedTime(System.currentTimeMillis()); + + this.repository.save(session); + + verify(this.sessions, times(1)).put(eq(session.getId()), eq(session), + isA(Long.class), eq(TimeUnit.SECONDS)); + } + + @Test + public void saveUnchanged() { + MapSession session = new MapSession(); + + this.repository.save(session); + + verify(this.sessions, times(1)).put(eq(session.getId()), eq(session), + isA(Long.class), eq(TimeUnit.SECONDS)); + // TODO - once save optimization is implemented, should be replaced with: + //verifyZeroInteractions(this.sessions); + } + + @Test + public void getSessionNotFound() { + String sessionId = "testSessionId"; + + MapSession session = this.repository.getSession(sessionId); + + assertThat(session).isNull(); + verify(this.sessions, times(1)).get(eq(sessionId)); + } + + @Test + public void getSessionExpired() { + MapSession expired = new MapSession(); + expired.setLastAccessedTime(System.currentTimeMillis() - + (MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS * 1000 + 1000)); + given(this.sessions.get(eq(expired.getId()))).willReturn(expired); + + MapSession session = this.repository.getSession(expired.getId()); + + assertThat(session).isNull(); + verify(this.sessions, times(1)).get(eq(expired.getId())); + verify(this.sessions, times(1)).remove(eq(expired.getId())); + } + + @Test + public void getSessionFound() { + MapSession saved = new MapSession(); + saved.setAttribute("savedName", "savedValue"); + given(this.sessions.get(eq(saved.getId()))).willReturn(saved); + + MapSession session = this.repository.getSession(saved.getId()); + + assertThat(session.getId()).isEqualTo(saved.getId()); + assertThat(session.getAttribute("savedName")).isEqualTo("savedValue"); + verify(this.sessions, times(1)).get(eq(saved.getId())); + } + + @Test + public void delete() { + String sessionId = "testSessionId"; + + this.repository.delete(sessionId); + + verify(this.sessions, times(1)).remove(eq(sessionId)); + } + + @Test + public void findByIndexNameAndIndexValueUnknownIndexName() { + String indexValue = "testIndexValue"; + + Map sessions = this.repository.findByIndexNameAndIndexValue( + "testIndexName", indexValue); + + assertThat(sessions).isEmpty(); + verifyZeroInteractions(this.sessions); + } + + @Test + public void findByIndexNameAndIndexValuePrincipalIndexNameNotFound() { + String principal = "username"; + + Map sessions = this.repository.findByIndexNameAndIndexValue( + FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, principal); + + assertThat(sessions).isEmpty(); + verify(this.sessions, times(1)).values(isA(EqualPredicate.class)); + } + + @Test + public void findByIndexNameAndIndexValuePrincipalIndexNameFound() { + String principal = "username"; + Authentication authentication = new UsernamePasswordAuthenticationToken(principal, + "notused", AuthorityUtils.createAuthorityList("ROLE_USER")); + List saved = new ArrayList(2); + MapSession saved1 = new MapSession(); + saved1.setAttribute(SPRING_SECURITY_CONTEXT, authentication); + saved.add(saved1); + MapSession saved2 = new MapSession(); + saved2.setAttribute(SPRING_SECURITY_CONTEXT, authentication); + saved.add(saved2); + given(this.sessions.values(isA(EqualPredicate.class))).willReturn(saved); + + Map sessions = this.repository.findByIndexNameAndIndexValue( + FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, principal); + + assertThat(sessions).hasSize(2); + verify(this.sessions, times(1)).values(isA(EqualPredicate.class)); + } + +} diff --git a/spring-session/src/test/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfigurationTests.java b/spring-session/src/test/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfigurationTests.java new file mode 100644 index 00000000..e907ea5f --- /dev/null +++ b/spring-session/src/test/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfigurationTests.java @@ -0,0 +1,197 @@ +/* + * Copyright 2014-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.session.hazelcast.config.annotation.web.http; + +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.core.IMap; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import org.springframework.beans.factory.UnsatisfiedDependencyException; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.hazelcast.HazelcastSessionRepository; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Matchers.isA; + +/** + * Tests for {@link HazelcastHttpSessionConfiguration}. + * + * @author Vedran Pavic + */ +@RunWith(MockitoJUnitRunner.class) +public class HazelcastHttpSessionConfigurationTests { + + private static final String MAP_NAME = "spring:test:sessions"; + + private static final int MAX_INACTIVE_INTERVAL_IN_SECONDS = 600; + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Mock + private static HazelcastInstance hazelcastInstance; + + @Mock + private IMap sessions; + + private AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + + @Before + public void setUp() { + given(hazelcastInstance.getMap(isA(String.class))).willReturn(this.sessions); + } + + @After + public void closeContext() { + if (this.context != null) { + this.context.close(); + } + } + + @Test + public void noHazelcastInstanceConfiguration() { + this.thrown.expect(UnsatisfiedDependencyException.class); + this.thrown.expectMessage("HazelcastInstance"); + + registerAndRefresh(EmptyConfiguration.class); + } + + @Test + public void defaultConfiguration() { + registerAndRefresh(DefaultConfiguration.class); + + assertThat(this.context.getBean(HazelcastSessionRepository.class)) + .isNotNull(); + } + + @Test + public void customTableName() { + registerAndRefresh(CustomSessionMapNameConfiguration.class); + + HazelcastSessionRepository repository = this.context + .getBean(HazelcastSessionRepository.class); + HazelcastHttpSessionConfiguration configuration = this.context + .getBean(HazelcastHttpSessionConfiguration.class); + assertThat(repository).isNotNull(); + assertThat(ReflectionTestUtils.getField(configuration, "sessionMapName")) + .isEqualTo(MAP_NAME); + } + + @Test + public void setCustomSessionMapName() { + registerAndRefresh(BaseConfiguration.class, + CustomSessionMapNameSetConfiguration.class); + + HazelcastSessionRepository repository = this.context + .getBean(HazelcastSessionRepository.class); + HazelcastHttpSessionConfiguration configuration = this.context + .getBean(HazelcastHttpSessionConfiguration.class); + assertThat(repository).isNotNull(); + assertThat(ReflectionTestUtils.getField(configuration, "sessionMapName")) + .isEqualTo(MAP_NAME); + } + + @Test + public void setCustomMaxInactiveIntervalInSeconds() { + registerAndRefresh(BaseConfiguration.class, + CustomMaxInactiveIntervalInSecondsSetConfiguration.class); + + HazelcastSessionRepository repository = this.context + .getBean(HazelcastSessionRepository.class); + assertThat(repository).isNotNull(); + assertThat(ReflectionTestUtils.getField(repository, "defaultMaxInactiveInterval")).isEqualTo( + MAX_INACTIVE_INTERVAL_IN_SECONDS); + } + + @Test + public void customMaxInactiveIntervalInSeconds() { + registerAndRefresh(CustomMaxInactiveIntervalInSecondsConfiguration.class); + + HazelcastSessionRepository repository = this.context + .getBean(HazelcastSessionRepository.class); + assertThat(repository).isNotNull(); + assertThat(ReflectionTestUtils.getField(repository, "defaultMaxInactiveInterval")) + .isEqualTo(MAX_INACTIVE_INTERVAL_IN_SECONDS); + } + + private void registerAndRefresh(Class... annotatedClasses) { + this.context.register(annotatedClasses); + this.context.refresh(); + } + + @Configuration + @EnableHazelcastHttpSession + static class EmptyConfiguration { + } + + static class BaseConfiguration { + + @Bean + public HazelcastInstance hazelcastInstance() { + return hazelcastInstance; + } + + } + + @Configuration + @EnableHazelcastHttpSession + static class DefaultConfiguration extends BaseConfiguration { + } + + @Configuration + @EnableHazelcastHttpSession(sessionMapName = MAP_NAME) + static class CustomSessionMapNameConfiguration extends BaseConfiguration { + } + + @Configuration + static class CustomSessionMapNameSetConfiguration + extends HazelcastHttpSessionConfiguration { + + CustomSessionMapNameSetConfiguration() { + setSessionMapName(MAP_NAME); + } + + } + + @Configuration + static class CustomMaxInactiveIntervalInSecondsSetConfiguration + extends HazelcastHttpSessionConfiguration { + + CustomMaxInactiveIntervalInSecondsSetConfiguration() { + setMaxInactiveIntervalInSeconds(MAX_INACTIVE_INTERVAL_IN_SECONDS); + } + + } + + @Configuration + @EnableHazelcastHttpSession(maxInactiveIntervalInSeconds = MAX_INACTIVE_INTERVAL_IN_SECONDS) + static class CustomMaxInactiveIntervalInSecondsConfiguration + extends BaseConfiguration { + } + +} diff --git a/spring-session/src/test/resources/org/springframework/session/hazelcast/config/annotation/web/http/hazelcast-custom-idle-time-map-name.xml b/spring-session/src/test/resources/org/springframework/session/hazelcast/config/annotation/web/http/hazelcast-custom-idle-time-map-name.xml index 6f5954b5..ab14415c 100644 --- a/spring-session/src/test/resources/org/springframework/session/hazelcast/config/annotation/web/http/hazelcast-custom-idle-time-map-name.xml +++ b/spring-session/src/test/resources/org/springframework/session/hazelcast/config/annotation/web/http/hazelcast-custom-idle-time-map-name.xml @@ -1,11 +1,13 @@ - + + spring-session-it-test-idle-time-map-name test-pass + 5701 @@ -24,14 +26,20 @@ + BINARY 1 0 0 300 - com.hazelcast.map.merge.PutIfAbsentMapMergePolicy - + com.hazelcast.map.merge.PutIfAbsentMapMergePolicy + + principalName + + + principalName + diff --git a/spring-session/src/test/resources/org/springframework/session/hazelcast/config/annotation/web/http/hazelcast-custom-idle-time.xml b/spring-session/src/test/resources/org/springframework/session/hazelcast/config/annotation/web/http/hazelcast-custom-idle-time.xml deleted file mode 100644 index 791f516d..00000000 --- a/spring-session/src/test/resources/org/springframework/session/hazelcast/config/annotation/web/http/hazelcast-custom-idle-time.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - spring-session-it-test-idle-time - test-pass - - - 5701 - - 0 - - - - - - 127.0.0.1 - - 127.0.0.1 - - - - - - - - BINARY - 1 - 0 - 0 - 150 - com.hazelcast.map.merge.PutIfAbsentMapMergePolicy - - - - diff --git a/spring-session/src/test/resources/org/springframework/session/hazelcast/config/annotation/web/http/hazelcast-custom-map-name.xml b/spring-session/src/test/resources/org/springframework/session/hazelcast/config/annotation/web/http/hazelcast-custom-map-name.xml index 1be4d1e2..919fc2fb 100644 --- a/spring-session/src/test/resources/org/springframework/session/hazelcast/config/annotation/web/http/hazelcast-custom-map-name.xml +++ b/spring-session/src/test/resources/org/springframework/session/hazelcast/config/annotation/web/http/hazelcast-custom-map-name.xml @@ -1,11 +1,13 @@ - + + spring-session-it-test-map-name test-pass + 5701 @@ -24,14 +26,20 @@ + BINARY 1 0 0 0 - com.hazelcast.map.merge.PutIfAbsentMapMergePolicy - + com.hazelcast.map.merge.PutIfAbsentMapMergePolicy + + principalName + + + principalName +