From 57a159ccce5fdcb856520d86a0535358d1d80320 Mon Sep 17 00:00:00 2001 From: Didier Loiseau Date: Fri, 23 Dec 2022 17:24:14 +0100 Subject: [PATCH 1/2] Implement ReactiveSessionRepository for Hazelcast Closes gh-831 --- .../hazelcast4/hazelcast4.gradle | 2 + ...Hazelcast4SessionUpdateEntryProcessor.java | 25 +- .../ReactiveHazelcastSessionRepository.java | 398 ++++++++++++++++++ ...activeHazelcastSessionRepositoryTests.java | 394 +++++++++++++++++ 4 files changed, 818 insertions(+), 1 deletion(-) create mode 100644 spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/ReactiveHazelcastSessionRepository.java create mode 100644 spring-session-hazelcast/hazelcast4/src/test/java/org/springframework/session/hazelcast/ReactiveHazelcastSessionRepositoryTests.java diff --git a/spring-session-hazelcast/hazelcast4/hazelcast4.gradle b/spring-session-hazelcast/hazelcast4/hazelcast4.gradle index 345358424..c29696aa9 100644 --- a/spring-session-hazelcast/hazelcast4/hazelcast4.gradle +++ b/spring-session-hazelcast/hazelcast4/hazelcast4.gradle @@ -22,6 +22,7 @@ artifacts { dependencies { api project(':spring-session-core') optional "com.hazelcast:hazelcast:4.2.4" + optional "io.projectreactor:reactor-core" api "org.springframework:spring-context" api "jakarta.annotation:jakarta.annotation-api" @@ -32,6 +33,7 @@ dependencies { testImplementation "org.springframework:spring-web" testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "org.springframework.security:spring-security-core" + testImplementation "io.projectreactor:reactor-test" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" integrationTestCompile "org.testcontainers:testcontainers" diff --git a/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4SessionUpdateEntryProcessor.java b/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4SessionUpdateEntryProcessor.java index eb29a74e1..dc36eb01b 100644 --- a/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4SessionUpdateEntryProcessor.java +++ b/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4SessionUpdateEntryProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2021 the original author or authors. + * Copyright 2014-2022 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. @@ -19,6 +19,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Map; +import java.util.Objects; import java.util.concurrent.TimeUnit; import com.hazelcast.map.EntryProcessor; @@ -31,6 +32,7 @@ * Hazelcast 4. * * @author Eleftheria Stein + * @author Didier Loiseau * @since 2.4.0 */ public class Hazelcast4SessionUpdateEntryProcessor implements EntryProcessor { @@ -80,4 +82,25 @@ void setDelta(Map delta) { this.delta = delta; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Hazelcast4SessionUpdateEntryProcessor that = (Hazelcast4SessionUpdateEntryProcessor) o; + // @formatter:off + return Objects.equals(this.lastAccessedTime, that.lastAccessedTime) + && Objects.equals(this.maxInactiveInterval, that.maxInactiveInterval) + && Objects.equals(this.delta, that.delta); + // @formatter:on + } + + @Override + public int hashCode() { + return Objects.hash(this.lastAccessedTime, this.maxInactiveInterval, this.delta); + } + } diff --git a/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/ReactiveHazelcastSessionRepository.java b/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/ReactiveHazelcastSessionRepository.java new file mode 100644 index 000000000..faf68d2cb --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/ReactiveHazelcastSessionRepository.java @@ -0,0 +1,398 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; + +import com.hazelcast.core.EntryEvent; +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; +import com.hazelcast.map.listener.EntryAddedListener; +import com.hazelcast.map.listener.EntryEvictedListener; +import com.hazelcast.map.listener.EntryExpiredListener; +import com.hazelcast.map.listener.EntryRemovedListener; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.session.MapSession; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.SaveMode; +import org.springframework.session.Session; +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 ReactiveSessionRepository} implementation using Hazelcast 4 that stores + * sessions in Hazelcast's distributed {@link IMap} using its {@code *Async} operations. + * + *

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

+ * Config config = new Config();
+ *
+ * // ... configure Hazelcast ...
+ *
+ * HazelcastInstance hazelcastInstance = Hazelcast.newHazelcastInstance(config);
+ *
+ * ReactiveHazelcastSessionRepository sessionRepository =
+ *         new ReactiveHazelcastSessionRepository(hazelcastInstance);
+ * 
+ * + * This implementation listens for events on the Hazelcast-backed + * ReactiveSessionRepository 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}
  • + *
  • entryExpired - {@link SessionExpiredEvent}
  • + *
  • entryRemoved - {@link SessionDeletedEvent}
  • + *
+ * + * @author Eleftheria Stein + * @author Didier Loiseau + * @since 2.6.4 + */ +public class ReactiveHazelcastSessionRepository + implements ReactiveSessionRepository, + EntryAddedListener, EntryEvictedListener, + EntryRemovedListener, EntryExpiredListener { + + /** + * The default name of map used by Spring Session to store sessions. + */ + public static final String DEFAULT_SESSION_MAP_NAME = "spring:session:sessions"; + + private static final Log logger = LogFactory.getLog(ReactiveHazelcastSessionRepository.class); + + private final HazelcastInstance hazelcastInstance; + + private ApplicationEventPublisher eventPublisher = (event) -> { + }; + + /** + * If non-null, this value is used to override + * {@link MapSession#setMaxInactiveInterval(Duration)}. + */ + private Integer defaultMaxInactiveInterval; + + private String sessionMapName = DEFAULT_SESSION_MAP_NAME; + + private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE; + + private IMap sessions; + + private UUID sessionListenerId; + + /** + * Create a new {@link ReactiveHazelcastSessionRepository} instance. + * @param hazelcastInstance the {@link HazelcastInstance} to use for managing sessions + */ + public ReactiveHazelcastSessionRepository(HazelcastInstance hazelcastInstance) { + Assert.notNull(hazelcastInstance, "HazelcastInstance must not be null"); + this.hazelcastInstance = hazelcastInstance; + } + + @PostConstruct + public void init() { + this.sessions = this.hazelcastInstance.getMap(this.sessionMapName); + this.sessionListenerId = this.sessions.addEntryListener(this, true); + } + + @PreDestroy + public 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; + } + + /** + * Set the name of map used to store sessions. + * @param sessionMapName the session map name + */ + public void setSessionMapName(String sessionMapName) { + Assert.hasText(sessionMapName, "Map name must not be empty"); + this.sessionMapName = sessionMapName; + } + + /** + * Set the save mode. + * @param saveMode the save mode + */ + public void setSaveMode(SaveMode saveMode) { + Assert.notNull(saveMode, "saveMode must not be null"); + this.saveMode = saveMode; + } + + @Override + public Mono createSession() { + return Mono.defer(() -> { + MapSession cached = new MapSession(); + if (this.defaultMaxInactiveInterval != null) { + cached.setMaxInactiveInterval(Duration.ofSeconds(this.defaultMaxInactiveInterval)); + } + return Mono.just(new HazelcastSession(cached, true)); + }); + } + + @Override + public Mono save(HazelcastSession session) { + CompletionStage result; + if (session.isNew) { + result = this.sessions.setAsync(session.getId(), session.getDelegate(), + session.getMaxInactiveInterval().getSeconds(), TimeUnit.SECONDS); + } + else if (session.sessionIdChanged) { + result = this.sessions.removeAsync(session.originalId).thenCompose((oldValue) -> { + session.originalId = session.getId(); + return this.sessions.setAsync(session.getId(), session.getDelegate(), + session.getMaxInactiveInterval().getSeconds(), TimeUnit.SECONDS); + }); + } + else if (session.hasChanges()) { + Hazelcast4SessionUpdateEntryProcessor entryProcessor = new Hazelcast4SessionUpdateEntryProcessor(); + if (session.lastAccessedTimeChanged) { + entryProcessor.setLastAccessedTime(session.getLastAccessedTime()); + } + if (session.maxInactiveIntervalChanged) { + entryProcessor.setMaxInactiveInterval(session.getMaxInactiveInterval()); + } + if (!session.delta.isEmpty()) { + entryProcessor.setDelta(new HashMap<>(session.delta)); + } + result = this.sessions.submitToKey(session.getId(), entryProcessor); + } + else { + result = CompletableFuture.completedFuture(null); + } + return Mono.fromCompletionStage(result.thenRun(session::clearChangeFlags)); + } + + @Override + public Mono findById(String id) { + return Mono.fromCompletionStage(this.sessions.getAsync(id)).flatMap((saved) -> { + if (saved.isExpired()) { + return deleteById(saved.getId()).then(Mono.empty()); + } + return Mono.just(new HazelcastSession(saved, false)); + }); + } + + @Override + public Mono deleteById(String id) { + return Mono.fromCompletionStage(this.sessions.removeAsync(id)).then(); + } + + @Override + public void entryAdded(EntryEvent event) { + MapSession session = event.getValue(); + if (session.getId().equals(session.getOriginalId())) { + if (logger.isDebugEnabled()) { + logger.debug("Session created with id: " + session.getId()); + } + this.eventPublisher.publishEvent(new SessionCreatedEvent(this, session)); + } + } + + @Override + 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())); + } + + @Override + public void entryRemoved(EntryEvent event) { + MapSession session = event.getOldValue(); + if (session != null) { + if (logger.isDebugEnabled()) { + logger.debug("Session deleted with id: " + session.getId()); + } + this.eventPublisher.publishEvent(new SessionDeletedEvent(this, session)); + } + } + + @Override + public void entryExpired(EntryEvent event) { + if (logger.isDebugEnabled()) { + logger.debug("Session expired with id: " + event.getOldValue().getId()); + } + this.eventPublisher.publishEvent(new SessionExpiredEvent(this, event.getOldValue())); + } + + /** + * A custom implementation of {@link Session} that uses a {@link MapSession} as the + * basis for its mapping. It keeps track if changes have been made since last save. + * + * @author Aleksandar Stojsavljevic + * @author Didier Loiseau + */ + final class HazelcastSession implements Session { + + private final MapSession delegate; + + private boolean isNew; + + private boolean sessionIdChanged; + + private boolean lastAccessedTimeChanged; + + private boolean maxInactiveIntervalChanged; + + private String originalId; + + private final Map delta = new HashMap<>(); + + HazelcastSession(MapSession cached, boolean isNew) { + this.delegate = cached; + this.isNew = isNew; + this.originalId = cached.getId(); + if (this.isNew || (ReactiveHazelcastSessionRepository.this.saveMode == SaveMode.ALWAYS)) { + getAttributeNames() + .forEach((attributeName) -> this.delta.put(attributeName, cached.getAttribute(attributeName))); + } + } + + @Override + public void setLastAccessedTime(Instant lastAccessedTime) { + this.delegate.setLastAccessedTime(lastAccessedTime); + this.lastAccessedTimeChanged = true; + } + + @Override + public boolean isExpired() { + return this.delegate.isExpired(); + } + + @Override + public Instant getCreationTime() { + return this.delegate.getCreationTime(); + } + + @Override + public String getId() { + return this.delegate.getId(); + } + + @Override + public String changeSessionId() { + String newSessionId = this.delegate.changeSessionId(); + this.sessionIdChanged = true; + return newSessionId; + } + + @Override + public Instant getLastAccessedTime() { + return this.delegate.getLastAccessedTime(); + } + + @Override + public void setMaxInactiveInterval(Duration interval) { + Assert.notNull(interval, "interval must not be null"); + this.delegate.setMaxInactiveInterval(interval); + this.maxInactiveIntervalChanged = true; + } + + @Override + public Duration getMaxInactiveInterval() { + return this.delegate.getMaxInactiveInterval(); + } + + @Override + public T getAttribute(String attributeName) { + T attributeValue = this.delegate.getAttribute(attributeName); + if (attributeValue != null + && ReactiveHazelcastSessionRepository.this.saveMode.equals(SaveMode.ON_GET_ATTRIBUTE)) { + this.delta.put(attributeName, attributeValue); + } + return attributeValue; + } + + @Override + public Set getAttributeNames() { + return this.delegate.getAttributeNames(); + } + + @Override + public void setAttribute(String attributeName, Object attributeValue) { + this.delegate.setAttribute(attributeName, attributeValue); + this.delta.put(attributeName, attributeValue); + } + + @Override + public void removeAttribute(String attributeName) { + setAttribute(attributeName, null); + } + + MapSession getDelegate() { + return this.delegate; + } + + boolean hasChanges() { + return (this.lastAccessedTimeChanged || this.maxInactiveIntervalChanged || !this.delta.isEmpty()); + } + + void clearChangeFlags() { + this.isNew = false; + this.lastAccessedTimeChanged = false; + this.sessionIdChanged = false; + this.maxInactiveIntervalChanged = false; + this.delta.clear(); + } + + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/test/java/org/springframework/session/hazelcast/ReactiveHazelcastSessionRepositoryTests.java b/spring-session-hazelcast/hazelcast4/src/test/java/org/springframework/session/hazelcast/ReactiveHazelcastSessionRepositoryTests.java new file mode 100644 index 000000000..4b65588f1 --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/test/java/org/springframework/session/hazelcast/ReactiveHazelcastSessionRepositoryTests.java @@ -0,0 +1,394 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; +import com.hazelcast.map.listener.MapListener; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import org.springframework.session.MapSession; +import org.springframework.session.SaveMode; +import org.springframework.session.hazelcast.ReactiveHazelcastSessionRepository.HazelcastSession; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.verify; +import static org.mockito.BDDMockito.verifyNoMoreInteractions; + +/** + * Tests for {@link ReactiveHazelcastSessionRepository}. + * + * @author Eleftheria Stein + * @author Didier Loiseau + */ +class ReactiveHazelcastSessionRepositoryTests { + + private final HazelcastInstance hazelcastInstance = mock(HazelcastInstance.class); + + @SuppressWarnings("unchecked") + private final IMap sessions = mock(IMap.class); + + private ReactiveHazelcastSessionRepository repository; + + @BeforeEach + void setUp() { + given(this.hazelcastInstance.getMap(anyString())).willReturn(this.sessions); + this.repository = new ReactiveHazelcastSessionRepository(this.hazelcastInstance); + this.repository.init(); + verify(this.sessions).addEntryListener(any(MapListener.class), anyBoolean()); + } + + @Test + void constructorNullHazelcastInstance() { + assertThatIllegalArgumentException().isThrownBy(() -> new ReactiveHazelcastSessionRepository(null)) + .withMessage("HazelcastInstance must not be null"); + } + + @Test + void setSaveModeNull() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setSaveMode(null)) + .withMessage("saveMode must not be null"); + } + + @Test + void createSessionDefaultMaxInactiveInterval() { + HazelcastSession session = this.repository.createSession().block(); + + // @formatter:off + assertThat(session).isNotNull() + .extracting(HazelcastSession::getMaxInactiveInterval) + .isEqualTo(new MapSession().getMaxInactiveInterval()); + // @formatter:on + verifyNoMoreInteractions(this.sessions); + } + + @Test + void createSessionCustomMaxInactiveInterval() { + int interval = 1; + this.repository.setDefaultMaxInactiveInterval(interval); + + HazelcastSession session = this.repository.createSession().block(); + + // @formatter:off + assertThat(session).isNotNull() + .extracting(HazelcastSession::getMaxInactiveInterval).isEqualTo(Duration.ofSeconds(interval)); + // @formatter:on + verifyNoMoreInteractions(this.sessions); + } + + @Test + void saveNewSession() { + HazelcastSession session = createTestSession(true); + CompletableFuture setFuture = new CompletableFuture<>(); + // @formatter:off + given(this.sessions.setAsync(session.getId(), session.getDelegate(), + MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS, TimeUnit.SECONDS)) + .willReturn(setFuture); + // @formatter:on + + // @formatter:off + this.repository.save(session) + .as(StepVerifier::create) + .expectSubscription() + .expectNoEvent(Duration.ZERO) + .then(() -> setFuture.complete(null)) + .verifyComplete(); + // @formatter:on + } + + @Test + void saveSessionIdChange() { + HazelcastSession session = createTestSession(false); + String oldSessionId = session.getId(); + session.changeSessionId(); + assertThat(session.getId()).isNotEqualTo(oldSessionId); + + CompletableFuture removeFuture = new CompletableFuture<>(); + given(this.sessions.removeAsync(oldSessionId)).willReturn(removeFuture); + CompletableFuture setFuture = new CompletableFuture<>(); + // @formatter:off + given(this.sessions.setAsync(session.getId(), session.getDelegate(), + MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS, TimeUnit.SECONDS)) + .willReturn(setFuture); + // @formatter:on + + // @formatter:off + this.repository.save(session) + .as(StepVerifier::create) + .expectSubscription() + .expectNoEvent(Duration.ZERO) + .then(() -> { + verify(this.sessions).removeAsync(oldSessionId); + verifyNoMoreInteractions(this.sessions); + + removeFuture.complete(null); + verify(this.sessions).setAsync(session.getId(), session.getDelegate(), + MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS, TimeUnit.SECONDS); + }) + .expectNoEvent(Duration.ZERO) + .then(() -> setFuture.complete(null)) + .verifyComplete(); + // @formatter:on + verifyNoMoreInteractions(this.sessions); + + // a second save would be a no-op + // @formatter:off + this.repository.save(session) + .as(StepVerifier::create) + .verifyComplete(); + // @formatter:on + verifyNoMoreInteractions(this.sessions); + } + + @Test + void saveUpdatedAttribute() { + HazelcastSession session = createTestSession(false); + session.setAttribute("testName", "testValue"); + assertThat(session.hasChanges()).isTrue(); + + Hazelcast4SessionUpdateEntryProcessor processor = createProcessor(null, null, + buildDelta("testName", "testValue")); + saveAndExpectUpdateUsingProcessor(session, processor); + } + + @Test + void removeAttribute() { + HazelcastSession session = createTestSession(false); + session.removeAttribute("testName"); + assertThat(session.hasChanges()).isTrue(); + + Hazelcast4SessionUpdateEntryProcessor processor = createProcessor(null, null, buildDelta("testName", null)); + saveAndExpectUpdateUsingProcessor(session, processor); + } + + @Test + void saveUpdatedLastAccessedTime() { + HazelcastSession session = createTestSession(false); + Instant accessedTime = Instant.now(); + session.setLastAccessedTime(accessedTime); + assertThat(session.hasChanges()).isTrue(); + + Hazelcast4SessionUpdateEntryProcessor processor = createProcessor(accessedTime, null, null); + saveAndExpectUpdateUsingProcessor(session, processor); + } + + @Test + void saveUpdatedMaxInactiveIntervalInSeconds() { + HazelcastSession session = createTestSession(false); + Duration interval = Duration.ofSeconds(1); + session.setMaxInactiveInterval(interval); + assertThat(session.hasChanges()).isTrue(); + + Hazelcast4SessionUpdateEntryProcessor processor = createProcessor(null, interval, null); + saveAndExpectUpdateUsingProcessor(session, processor); + } + + private void saveAndExpectUpdateUsingProcessor(HazelcastSession session, + Hazelcast4SessionUpdateEntryProcessor processor) { + CompletableFuture submitFuture = new CompletableFuture<>(); + given(this.sessions.submitToKey(session.getId(), processor)).willReturn(submitFuture); + + // @formatter:off + this.repository.save(session) + .as(StepVerifier::create) + .expectSubscription() + .expectNoEvent(Duration.ZERO) + .then(() -> submitFuture.complete(Boolean.TRUE)) + .verifyComplete(); + // @formatter:on + + assertThat(session.hasChanges()).isFalse(); + } + + @Test + void saveUnchanged() { + HazelcastSession session = createTestSession(false); + assertThat(session.hasChanges()).isFalse(); + // @formatter:off + this.repository.save(session) + .as(StepVerifier::create) + .verifyComplete(); + // @formatter:on + assertThat(session.hasChanges()).isFalse(); + } + + @Test + void getSessionNotFound() { + String sessionId = "testSessionId"; + given(this.sessions.getAsync(sessionId)).willReturn(CompletableFuture.completedFuture(null)); + + // @formatter:off + this.repository.findById(sessionId) + .as(StepVerifier::create) + .verifyComplete(); + // @formatter:on + } + + @Test + void getSessionExpired() { + MapSession expired = new MapSession(); + expired.setLastAccessedTime(Instant.now().minusSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS + 1)); + given(this.sessions.getAsync(eq(expired.getId()))).willReturn(CompletableFuture.completedFuture(expired)); + CompletableFuture removeFuture = new CompletableFuture<>(); + given(this.sessions.removeAsync(expired.getId())).willReturn(removeFuture); + + // @formatter:off + this.repository.findById(expired.getId()) + .as(StepVerifier::create) + .expectSubscription() + .expectNoEvent(Duration.ZERO) + .then(() -> removeFuture.complete(null)) + .verifyComplete(); + // @formatter:on + } + + @Test + void getSessionFound() { + MapSession saved = new MapSession(); + saved.setAttribute("savedName", "savedValue"); + CompletableFuture getFuture = new CompletableFuture<>(); + given(this.sessions.getAsync(eq(saved.getId()))).willReturn(getFuture); + + // @formatter:off + this.repository.findById(saved.getId()) + .as(StepVerifier::create) + .then(() -> getFuture.complete(saved)) + .expectNextMatches((hzSession) -> hzSession.getDelegate().equals(saved) + && hzSession.getId().equals(saved.getId()) + && hzSession.getAttribute("savedName").equals("savedValue") + && !hzSession.hasChanges()) + .verifyComplete(); + // @formatter:on + } + + @Test + void delete() { + String sessionId = "testSessionId"; + CompletableFuture removeFuture = new CompletableFuture<>(); + given(this.sessions.removeAsync(sessionId)).willReturn(removeFuture); + + // @formatter:off + this.repository.deleteById(sessionId) + .as(StepVerifier::create) + .expectSubscription() + .expectNoEvent(Duration.ZERO) + .then(() -> removeFuture.complete(null)) + .verifyComplete(); + // @formatter:on + } + + @Test // gh-1120 + void getAttributeNamesAndRemove() { + HazelcastSession session = createTestSession(false); + session.setAttribute("attribute1", "value1"); + session.setAttribute("attribute2", "value2"); + + for (String attributeName : session.getAttributeNames()) { + session.removeAttribute(attributeName); + } + + assertThat(session.getAttributeNames()).isEmpty(); + } + + @Test + void saveWithSaveModeOnSetAttribute() { + this.repository.setSaveMode(SaveMode.ON_SET_ATTRIBUTE); + MapSession delegate = new MapSession(); + delegate.setAttribute("attribute1", "value1"); + delegate.setAttribute("attribute2", "value2"); + delegate.setAttribute("attribute3", "value3"); + HazelcastSession session = this.repository.new HazelcastSession(delegate, false); + session.getAttribute("attribute2"); + session.setAttribute("attribute3", "value4"); + + Hazelcast4SessionUpdateEntryProcessor processor = createProcessor(null, null, + buildDelta("attribute3", "value4")); + saveAndExpectUpdateUsingProcessor(session, processor); + } + + @Test + void saveWithSaveModeOnGetAttribute() { + this.repository.setSaveMode(SaveMode.ON_GET_ATTRIBUTE); + MapSession delegate = new MapSession(); + delegate.setAttribute("attribute1", "value1"); + delegate.setAttribute("attribute2", "value2"); + delegate.setAttribute("attribute3", "value3"); + HazelcastSession session = this.repository.new HazelcastSession(delegate, false); + session.getAttribute("attribute2"); + session.setAttribute("attribute3", "value4"); + + HashMap delta = new HashMap<>(); + delta.put("attribute2", "value2"); + delta.put("attribute3", "value4"); + Hazelcast4SessionUpdateEntryProcessor processor = createProcessor(null, null, delta); + saveAndExpectUpdateUsingProcessor(session, processor); + } + + @Test + void saveWithSaveModeAlways() { + this.repository.setSaveMode(SaveMode.ALWAYS); + MapSession delegate = new MapSession(); + delegate.setAttribute("attribute1", "value1"); + delegate.setAttribute("attribute2", "value2"); + delegate.setAttribute("attribute3", "value3"); + HazelcastSession session = this.repository.new HazelcastSession(delegate, false); + session.getAttribute("attribute2"); + session.setAttribute("attribute3", "value4"); + + HashMap delta = new HashMap<>(); + delta.put("attribute1", "value1"); + delta.put("attribute2", "value2"); + delta.put("attribute3", "value4"); + Hazelcast4SessionUpdateEntryProcessor processor = createProcessor(null, null, delta); + saveAndExpectUpdateUsingProcessor(session, processor); + } + + private HazelcastSession createTestSession(boolean isNew) { + return this.repository.new HazelcastSession(new MapSession(), isNew); + } + + private static Hazelcast4SessionUpdateEntryProcessor createProcessor(Instant accessedTime, + Duration maxInactiveInterval, HashMap delta) { + Hazelcast4SessionUpdateEntryProcessor processor = new Hazelcast4SessionUpdateEntryProcessor(); + processor.setLastAccessedTime(accessedTime); + processor.setMaxInactiveInterval(maxInactiveInterval); + processor.setDelta(delta); + return processor; + } + + private static HashMap buildDelta(String key, String value) { + HashMap delta = new HashMap<>(); + delta.put(key, value); + return delta; + } + +} From 22af8330c9f7bf658b6bed3c407597380b050041 Mon Sep 17 00:00:00 2001 From: Didier Loiseau Date: Tue, 27 Dec 2022 17:40:42 +0100 Subject: [PATCH 2/2] Annotation support and integration tests for ReactiveHazelcastSessionRepository --- .../hazelcast4/hazelcast4.gradle | 5 +- ...ctiveHazelcastSessionRepositoryITests.java | 228 ++++++++++ ...ctiveHazelcastSessionRepositoryITests.java | 79 ++++ ...ctiveHazelcastSessionRepositoryITests.java | 52 +++ ...activeHazelcastSessionRepositoryTests.java | 243 +++++++++++ ...HazelcastWebSessionConfigurationTests.java | 388 ++++++++++++++++++ .../spring-session-hazelcast.gradle | 1 + .../web/server/EnableHazelcastWebSession.java | 93 +++++ .../HazelcastWebSessionConfiguration.java | 130 ++++++ 9 files changed, 1217 insertions(+), 2 deletions(-) create mode 100644 spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/AbstractReactiveHazelcastSessionRepositoryITests.java create mode 100644 spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/ClientServerReactiveHazelcastSessionRepositoryITests.java create mode 100644 spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/EmbeddedReactiveHazelcastSessionRepositoryITests.java create mode 100644 spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/SessionEventReactiveHazelcastSessionRepositoryTests.java create mode 100644 spring-session-hazelcast/hazelcast4/src/test/java/org/springframework/session/hazelcast/config/annotation/web/server/HazelcastWebSessionConfigurationTests.java create mode 100644 spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/server/EnableHazelcastWebSession.java create mode 100644 spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/server/HazelcastWebSessionConfiguration.java diff --git a/spring-session-hazelcast/hazelcast4/hazelcast4.gradle b/spring-session-hazelcast/hazelcast4/hazelcast4.gradle index c29696aa9..7c3de7dd8 100644 --- a/spring-session-hazelcast/hazelcast4/hazelcast4.gradle +++ b/spring-session-hazelcast/hazelcast4/hazelcast4.gradle @@ -23,6 +23,7 @@ dependencies { api project(':spring-session-core') optional "com.hazelcast:hazelcast:4.2.4" optional "io.projectreactor:reactor-core" + optional "org.springframework:spring-web" api "org.springframework:spring-context" api "jakarta.annotation:jakarta.annotation-api" @@ -34,10 +35,10 @@ dependencies { testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "org.springframework.security:spring-security-core" testImplementation "io.projectreactor:reactor-test" + testImplementation "com.hazelcast:hazelcast:4.2.4" + testImplementation project(":spring-session-hazelcast") testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" integrationTestCompile "org.testcontainers:testcontainers" - integrationTestCompile "com.hazelcast:hazelcast:4.2.4" - integrationTestCompile project(":spring-session-hazelcast") } diff --git a/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/AbstractReactiveHazelcastSessionRepositoryITests.java b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/AbstractReactiveHazelcastSessionRepositoryITests.java new file mode 100644 index 000000000..5379b3cfe --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/AbstractReactiveHazelcastSessionRepositoryITests.java @@ -0,0 +1,228 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.time.Duration; +import java.time.Instant; + +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.session.MapSession; +import org.springframework.session.hazelcast.ReactiveHazelcastSessionRepository.HazelcastSession; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Base class for {@link ReactiveHazelcastSessionRepository} integration tests. + * + * @author Eleftheria Stein + * @author Didier Loiseau + */ +abstract class AbstractReactiveHazelcastSessionRepositoryITests { + + @Autowired + private HazelcastInstance hazelcastInstance; + + @Autowired + private ReactiveHazelcastSessionRepository repository; + + @Test + void createAndDestroySession() { + HazelcastSession sessionToSave = this.repository.createSession().block(); + String sessionId = sessionToSave.getId(); + + IMap hazelcastMap = this.hazelcastInstance + .getMap(ReactiveHazelcastSessionRepository.DEFAULT_SESSION_MAP_NAME); + + this.repository.save(sessionToSave).block(); + + assertThat(hazelcastMap.get(sessionId)).isEqualTo(sessionToSave); + + this.repository.deleteById(sessionId).block(); + + assertThat(hazelcastMap.get(sessionId)).isNull(); + } + + @Test + void changeSessionIdWhenOnlyChangeId() { + String attrName = "changeSessionId"; + String attrValue = "changeSessionId-value"; + HazelcastSession toSave = this.repository.createSession().block(); + toSave.setAttribute(attrName, attrValue); + + this.repository.save(toSave).block(); + + HazelcastSession findById = this.repository.findById(toSave.getId()).block(); + + assertThat(findById.getAttribute(attrName)).isEqualTo(attrValue); + + String originalFindById = findById.getId(); + String changeSessionId = findById.changeSessionId(); + + this.repository.save(findById).block(); + + assertThat(this.repository.findById(originalFindById).block()).isNull(); + + HazelcastSession findByChangeSessionId = this.repository.findById(changeSessionId).block(); + + assertThat(findByChangeSessionId.getAttribute(attrName)).isEqualTo(attrValue); + + this.repository.deleteById(changeSessionId).block(); + } + + @Test + void changeSessionIdWhenChangeTwice() { + HazelcastSession toSave = this.repository.createSession().block(); + + this.repository.save(toSave).block(); + + String originalId = toSave.getId(); + String changeId1 = toSave.changeSessionId(); + String changeId2 = toSave.changeSessionId(); + + this.repository.save(toSave).block(); + + assertThat(this.repository.findById(originalId).block()).isNull(); + assertThat(this.repository.findById(changeId1).block()).isNull(); + assertThat(this.repository.findById(changeId2).block()).isNotNull(); + + this.repository.deleteById(changeId2).block(); + } + + @Test + void changeSessionIdWhenSetAttributeOnChangedSession() { + String attrName = "changeSessionId"; + String attrValue = "changeSessionId-value"; + + HazelcastSession toSave = this.repository.createSession().block(); + + this.repository.save(toSave).block(); + + HazelcastSession findById = this.repository.findById(toSave.getId()).block(); + + findById.setAttribute(attrName, attrValue); + + String originalFindById = findById.getId(); + String changeSessionId = findById.changeSessionId(); + + this.repository.save(findById).block(); + + assertThat(this.repository.findById(originalFindById).block()).isNull(); + + HazelcastSession findByChangeSessionId = this.repository.findById(changeSessionId).block(); + + assertThat(findByChangeSessionId.getAttribute(attrName)).isEqualTo(attrValue); + + this.repository.deleteById(changeSessionId).block(); + } + + @Test + void changeSessionIdWhenHasNotSaved() { + HazelcastSession toSave = this.repository.createSession().block(); + String originalId = toSave.getId(); + toSave.changeSessionId(); + + this.repository.save(toSave).block(); + + assertThat(this.repository.findById(toSave.getId()).block()).isNotNull(); + assertThat(this.repository.findById(originalId).block()).isNull(); + + this.repository.deleteById(toSave.getId()).block(); + } + + @Test // gh-1076 + void attemptToUpdateSessionAfterDelete() { + HazelcastSession session = this.repository.createSession().block(); + String sessionId = session.getId(); + this.repository.save(session).block(); + session = this.repository.findById(sessionId).block(); + session.setAttribute("attributeName", "attributeValue"); + this.repository.deleteById(sessionId).block(); + this.repository.save(session).block(); + + assertThat(this.repository.findById(sessionId).block()).isNull(); + } + + @Test + void createAndUpdateSession() { + HazelcastSession session = this.repository.createSession().block(); + String sessionId = session.getId(); + + this.repository.save(session).block(); + + session = this.repository.findById(sessionId).block(); + session.setAttribute("attributeName", "attributeValue"); + + this.repository.save(session).block(); + + assertThat(this.repository.findById(sessionId).block()).isNotNull() + .extracting((s) -> s.getAttribute("attributeName")).isEqualTo("attributeValue"); + + this.repository.deleteById(sessionId).block(); + } + + @Test + void createAndUpdateSessionWhileKeepingOriginalTimeToLiveConfiguredOnRepository() { + final Duration defaultSessionTimeout = Duration.ofSeconds(1800); + + final IMap hazelcastMap = this.hazelcastInstance + .getMap(ReactiveHazelcastSessionRepository.DEFAULT_SESSION_MAP_NAME); + + HazelcastSession session = this.repository.createSession().block(); + String sessionId = session.getId(); + this.repository.save(session).block(); + + assertThat(session.getMaxInactiveInterval()).isEqualTo(defaultSessionTimeout); + assertThat(hazelcastMap.getEntryView(sessionId).getTtl()).isEqualTo(defaultSessionTimeout.toMillis()); + + session = this.repository.findById(sessionId).block(); + session.setLastAccessedTime(Instant.now()); + this.repository.save(session).block(); + + session = this.repository.findById(sessionId).block(); + assertThat(session.getMaxInactiveInterval()).isEqualTo(defaultSessionTimeout); + assertThat(hazelcastMap.getEntryView(sessionId).getTtl()).isEqualTo(defaultSessionTimeout.toMillis()); + } + + @Test + void createAndUpdateSessionWhileKeepingTimeToLiveSetOnSession() { + final Duration individualSessionTimeout = Duration.ofSeconds(23); + + final IMap hazelcastMap = this.hazelcastInstance + .getMap(ReactiveHazelcastSessionRepository.DEFAULT_SESSION_MAP_NAME); + + HazelcastSession session = this.repository.createSession().block(); + session.setMaxInactiveInterval(individualSessionTimeout); + String sessionId = session.getId(); + this.repository.save(session).block(); + + assertThat(session.getMaxInactiveInterval()).isEqualTo(individualSessionTimeout); + assertThat(hazelcastMap.getEntryView(sessionId).getTtl()).isEqualTo(individualSessionTimeout.toMillis()); + + session = this.repository.findById(sessionId).block(); + session.setAttribute("attribute", "value"); + this.repository.save(session).block(); + + session = this.repository.findById(sessionId).block(); + assertThat(session.getMaxInactiveInterval()).isEqualTo(individualSessionTimeout); + assertThat(hazelcastMap.getEntryView(sessionId).getTtl()).isEqualTo(individualSessionTimeout.toMillis()); + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/ClientServerReactiveHazelcastSessionRepositoryITests.java b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/ClientServerReactiveHazelcastSessionRepositoryITests.java new file mode 100644 index 000000000..cab6ce087 --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/ClientServerReactiveHazelcastSessionRepositoryITests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.client.HazelcastClient; +import com.hazelcast.client.config.ClientConfig; +import com.hazelcast.core.HazelcastInstance; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.MountableFile; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.MapSession; +import org.springframework.session.Session; +import org.springframework.session.hazelcast.config.annotation.web.server.EnableHazelcastWebSession; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; + +/** + * Integration tests for {@link ReactiveHazelcastSessionRepository} using client-server + * topology. + * + * @author Eleftheria Stein + * @author Didier Loiseau + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +@WebAppConfiguration +class ClientServerReactiveHazelcastSessionRepositoryITests extends AbstractReactiveHazelcastSessionRepositoryITests { + + private static GenericContainer container = new GenericContainer<>("hazelcast/hazelcast:4.2.4") + .withExposedPorts(5701).withCopyFileToContainer(MountableFile.forClasspathResource("/hazelcast-server.xml"), + "/opt/hazelcast/hazelcast.xml"); + + @BeforeAll + static void setUpClass() { + container.start(); + } + + @AfterAll + static void tearDownClass() { + container.stop(); + } + + @Configuration + @EnableHazelcastWebSession + static class HazelcastSessionConfig { + + @Bean + HazelcastInstance hazelcastInstance() { + ClientConfig clientConfig = new ClientConfig(); + clientConfig.getNetworkConfig() + .addAddress(container.getContainerIpAddress() + ":" + container.getFirstMappedPort()); + clientConfig.getUserCodeDeploymentConfig().setEnabled(true).addClass(Session.class) + .addClass(MapSession.class).addClass(Hazelcast4SessionUpdateEntryProcessor.class); + return HazelcastClient.newHazelcastClient(clientConfig); + } + + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/EmbeddedReactiveHazelcastSessionRepositoryITests.java b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/EmbeddedReactiveHazelcastSessionRepositoryITests.java new file mode 100644 index 000000000..53fea6cc3 --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/EmbeddedReactiveHazelcastSessionRepositoryITests.java @@ -0,0 +1,52 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.core.HazelcastInstance; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.hazelcast.config.annotation.web.server.EnableHazelcastWebSession; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; + +/** + * Integration tests for {@link ReactiveHazelcastSessionRepository} using embedded + * topology. + * + * @author Eleftheria Stein + * @author Didier Loiseau + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +@WebAppConfiguration +class EmbeddedReactiveHazelcastSessionRepositoryITests extends AbstractReactiveHazelcastSessionRepositoryITests { + + @EnableHazelcastWebSession + @Configuration + static class HazelcastSessionConfig { + + @Bean + HazelcastInstance hazelcastInstance() { + return Hazelcast4ITestUtils.embeddedHazelcastServer(); + } + + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/SessionEventReactiveHazelcastSessionRepositoryTests.java b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/SessionEventReactiveHazelcastSessionRepositoryTests.java new file mode 100644 index 000000000..9d464272f --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/SessionEventReactiveHazelcastSessionRepositoryTests.java @@ -0,0 +1,243 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.time.Duration; +import java.time.Instant; + +import com.hazelcast.core.HazelcastInstance; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.Session; +import org.springframework.session.SessionRepository; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.session.events.SessionDeletedEvent; +import org.springframework.session.events.SessionExpiredEvent; +import org.springframework.session.hazelcast.config.annotation.web.server.EnableHazelcastWebSession; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Ensure that the appropriate SessionEvents are fired at the expected times. Additionally + * ensure that the interactions with the {@link SessionRepository} abstraction behave as + * expected after each SessionEvent. + * + * @author Eleftheria Stein + * @author Didier Loiseau + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +@WebAppConfiguration +class SessionEventReactiveHazelcastSessionRepositoryTests { + + private static final int MAX_INACTIVE_INTERVAL_IN_SECONDS = 2; + + @Autowired + private ReactiveSessionRepository repository; + + @Autowired + private SessionEventRegistry registry; + + @BeforeEach + void setup() { + this.registry.clear(); + } + + @Test + void saveSessionTest() throws InterruptedException { + String username = "saves-" + System.currentTimeMillis(); + + S sessionToSave = this.repository.createSession().block(); + + String expectedAttributeName = "a"; + String expectedAttributeValue = "b"; + sessionToSave.setAttribute(expectedAttributeName, expectedAttributeValue); + Authentication toSaveToken = new UsernamePasswordAuthenticationToken(username, "password", + AuthorityUtils.createAuthorityList("ROLE_USER")); + SecurityContext toSaveContext = SecurityContextHolder.createEmptyContext(); + toSaveContext.setAuthentication(toSaveToken); + sessionToSave.setAttribute("SPRING_SECURITY_CONTEXT", toSaveContext); + sessionToSave.setAttribute(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, username); + + this.repository.save(sessionToSave).block(); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionCreatedEvent.class); + + Session session = this.repository.findById(sessionToSave.getId()).block(); + + assertThat(session.getId()).isEqualTo(sessionToSave.getId()); + assertThat(session.getAttributeNames()).isEqualTo(sessionToSave.getAttributeNames()); + assertThat(session.getAttribute(expectedAttributeName)) + .isEqualTo(sessionToSave.getAttribute(expectedAttributeName)); + } + + @Test + void expiredSessionTest() throws InterruptedException { + S sessionToSave = this.repository.createSession().block(); + + this.repository.save(sessionToSave).block(); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionCreatedEvent.class); + this.registry.clear(); + + assertThat(sessionToSave.getMaxInactiveInterval()) + .isEqualTo(Duration.ofSeconds(MAX_INACTIVE_INTERVAL_IN_SECONDS)); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionExpiredEvent.class); + + assertThat(this.repository.findById(sessionToSave.getId()).block()).isNull(); + } + + @Test + void deletedSessionTest() throws InterruptedException { + S sessionToSave = this.repository.createSession().block(); + + this.repository.save(sessionToSave).block(); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionCreatedEvent.class); + this.registry.clear(); + + this.repository.deleteById(sessionToSave.getId()).block(); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionDeletedEvent.class); + + assertThat(this.repository.findById(sessionToSave.getId()).block()).isNull(); + } + + @Test + void saveUpdatesTimeToLiveTest() throws InterruptedException { + S sessionToSave = this.repository.createSession().block(); + sessionToSave.setMaxInactiveInterval(Duration.ofSeconds(3)); + this.repository.save(sessionToSave).block(); + + Thread.sleep(2000); + + // Get and save the session like SessionRepositoryFilter would. + S sessionToUpdate = this.repository.findById(sessionToSave.getId()).block(); + sessionToUpdate.setLastAccessedTime(Instant.now()); + this.repository.save(sessionToUpdate).block(); + + Thread.sleep(2000); + + assertThat(this.repository.findById(sessionToUpdate.getId()).block()).isNotNull(); + } + + @Test // gh-1077 + void changeSessionIdNoEventTest() throws InterruptedException { + S sessionToSave = this.repository.createSession().block(); + sessionToSave.setMaxInactiveInterval(Duration.ofMinutes(30)); + + this.repository.save(sessionToSave).block(); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionCreatedEvent.class); + this.registry.clear(); + + sessionToSave.changeSessionId(); + this.repository.save(sessionToSave).block(); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isFalse(); + } + + @Test // gh-1300 + void updateMaxInactiveIntervalTest() throws InterruptedException { + S sessionToSave = this.repository.createSession().block(); + sessionToSave.setMaxInactiveInterval(Duration.ofMinutes(30)); + this.repository.save(sessionToSave).block(); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionCreatedEvent.class); + this.registry.clear(); + + S sessionToUpdate = this.repository.findById(sessionToSave.getId()).block(); + sessionToUpdate.setLastAccessedTime(Instant.now()); + sessionToUpdate.setMaxInactiveInterval(Duration.ofSeconds(1)); + this.repository.save(sessionToUpdate).block(); + + assertThat(this.registry.receivedEvent(sessionToUpdate.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToUpdate.getId())) + .isInstanceOf(SessionExpiredEvent.class); + assertThat(this.repository.findById(sessionToUpdate.getId()).block()).isNull(); + } + + @Test // gh-1899 + void updateSessionAndExpireAfterOriginalTimeToLiveTest() throws InterruptedException { + S sessionToSave = this.repository.createSession().block(); + this.repository.save(sessionToSave).block(); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionCreatedEvent.class); + this.registry.clear(); + + S sessionToUpdate = this.repository.findById(sessionToSave.getId()).block(); + sessionToUpdate.setLastAccessedTime(Instant.now()); + this.repository.save(sessionToUpdate).block(); + + assertThat(this.registry.receivedEvent(sessionToUpdate.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToUpdate.getId())) + .isInstanceOf(SessionExpiredEvent.class); + // Assert this after the expired event was received because it would otherwise do + // its own expiration check and explicitly delete the session from Hazelcast + // regardless of the TTL of the IMap entry. + assertThat(this.repository.findById(sessionToUpdate.getId()).block()).isNull(); + } + + @Configuration + @EnableHazelcastWebSession(maxInactiveIntervalInSeconds = MAX_INACTIVE_INTERVAL_IN_SECONDS) + static class HazelcastSessionConfig { + + @Bean + HazelcastInstance embeddedHazelcast() { + return Hazelcast4ITestUtils.embeddedHazelcastServer(); + } + + @Bean + SessionEventRegistry sessionEventRegistry() { + return new SessionEventRegistry(); + } + + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/test/java/org/springframework/session/hazelcast/config/annotation/web/server/HazelcastWebSessionConfigurationTests.java b/spring-session-hazelcast/hazelcast4/src/test/java/org/springframework/session/hazelcast/config/annotation/web/server/HazelcastWebSessionConfigurationTests.java new file mode 100644 index 000000000..a8833a97c --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/test/java/org/springframework/session/hazelcast/config/annotation/web/server/HazelcastWebSessionConfigurationTests.java @@ -0,0 +1,388 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.server; + +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.BDDMockito; +import org.mockito.Mockito; + +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.annotation.Order; +import org.springframework.session.SaveMode; +import org.springframework.session.config.ReactiveSessionRepositoryCustomizer; +import org.springframework.session.hazelcast.ReactiveHazelcastSessionRepository; +import org.springframework.session.hazelcast.config.annotation.SpringSessionHazelcastInstance; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Tests for {@link HazelcastWebSessionConfiguration}. + * + * @author Vedran Pavic + * @author Aleksandar Stojsavljevic + * @author Didier Loiseau + */ +class HazelcastWebSessionConfigurationTests { + + private static final String MAP_NAME = "spring:test:sessions"; + + private static final int MAX_INACTIVE_INTERVAL_IN_SECONDS = 600; + + private final AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + + @AfterEach + void closeContext() { + this.context.close(); + } + + @Test + void noHazelcastInstanceConfiguration() { + Assertions.assertThatExceptionOfType(BeanCreationException.class) + .isThrownBy(() -> registerAndRefresh(NoHazelcastInstanceConfiguration.class)) + .withMessageContaining("HazelcastInstance"); + } + + @Test + void defaultConfiguration() { + registerAndRefresh(DefaultConfiguration.class); + + Assertions.assertThat(this.context.getBean(ReactiveHazelcastSessionRepository.class)).isNotNull(); + } + + @Test + void customTableName() { + registerAndRefresh(CustomSessionMapNameConfiguration.class); + + ReactiveHazelcastSessionRepository repository = this.context.getBean(ReactiveHazelcastSessionRepository.class); + HazelcastWebSessionConfiguration configuration = this.context.getBean(HazelcastWebSessionConfiguration.class); + Assertions.assertThat(repository).isNotNull(); + Assertions.assertThat(ReflectionTestUtils.getField(configuration, "sessionMapName")).isEqualTo(MAP_NAME); + } + + @Test + void setCustomSessionMapName() { + registerAndRefresh(BaseConfiguration.class, CustomSessionMapNameSetConfiguration.class); + + ReactiveHazelcastSessionRepository repository = this.context.getBean(ReactiveHazelcastSessionRepository.class); + HazelcastWebSessionConfiguration configuration = this.context.getBean(HazelcastWebSessionConfiguration.class); + Assertions.assertThat(repository).isNotNull(); + Assertions.assertThat(ReflectionTestUtils.getField(configuration, "sessionMapName")).isEqualTo(MAP_NAME); + } + + @Test + void setCustomMaxInactiveIntervalInSeconds() { + registerAndRefresh(BaseConfiguration.class, CustomMaxInactiveIntervalInSecondsSetConfiguration.class); + + ReactiveHazelcastSessionRepository repository = this.context.getBean(ReactiveHazelcastSessionRepository.class); + Assertions.assertThat(repository).isNotNull(); + Assertions.assertThat(ReflectionTestUtils.getField(repository, "defaultMaxInactiveInterval")) + .isEqualTo(MAX_INACTIVE_INTERVAL_IN_SECONDS); + } + + @Test + void customMaxInactiveIntervalInSeconds() { + registerAndRefresh(CustomMaxInactiveIntervalInSecondsConfiguration.class); + + ReactiveHazelcastSessionRepository repository = this.context.getBean(ReactiveHazelcastSessionRepository.class); + Assertions.assertThat(repository).isNotNull(); + Assertions.assertThat(ReflectionTestUtils.getField(repository, "defaultMaxInactiveInterval")) + .isEqualTo(MAX_INACTIVE_INTERVAL_IN_SECONDS); + } + + @Test + void customSaveModeAnnotation() { + registerAndRefresh(BaseConfiguration.class, CustomSaveModeExpressionAnnotationConfiguration.class); + Assertions.assertThat(this.context.getBean(ReactiveHazelcastSessionRepository.class)) + .hasFieldOrPropertyWithValue("saveMode", SaveMode.ALWAYS); + } + + @Test + void customSaveModeSetter() { + registerAndRefresh(BaseConfiguration.class, CustomSaveModeExpressionSetterConfiguration.class); + Assertions.assertThat(this.context.getBean(ReactiveHazelcastSessionRepository.class)) + .hasFieldOrPropertyWithValue("saveMode", SaveMode.ALWAYS); + } + + @Test + void qualifiedHazelcastInstanceConfiguration() { + registerAndRefresh(QualifiedHazelcastInstanceConfiguration.class); + + ReactiveHazelcastSessionRepository repository = this.context.getBean(ReactiveHazelcastSessionRepository.class); + HazelcastInstance hazelcastInstance = this.context.getBean("qualifiedHazelcastInstance", + HazelcastInstance.class); + Assertions.assertThat(repository).isNotNull(); + Assertions.assertThat(hazelcastInstance).isNotNull(); + Assertions.assertThat(ReflectionTestUtils.getField(repository, "sessions")) + .isEqualTo(QualifiedHazelcastInstanceConfiguration.qualifiedHazelcastInstanceSessions); + } + + @Test + void primaryHazelcastInstanceConfiguration() { + registerAndRefresh(PrimaryHazelcastInstanceConfiguration.class); + + ReactiveHazelcastSessionRepository repository = this.context.getBean(ReactiveHazelcastSessionRepository.class); + HazelcastInstance hazelcastInstance = this.context.getBean("primaryHazelcastInstance", HazelcastInstance.class); + Assertions.assertThat(repository).isNotNull(); + Assertions.assertThat(hazelcastInstance).isNotNull(); + Assertions.assertThat(ReflectionTestUtils.getField(repository, "sessions")) + .isEqualTo(PrimaryHazelcastInstanceConfiguration.primaryHazelcastInstanceSessions); + } + + @Test + void qualifiedAndPrimaryHazelcastInstanceConfiguration() { + registerAndRefresh(QualifiedAndPrimaryHazelcastInstanceConfiguration.class); + + ReactiveHazelcastSessionRepository repository = this.context.getBean(ReactiveHazelcastSessionRepository.class); + HazelcastInstance hazelcastInstance = this.context.getBean("qualifiedHazelcastInstance", + HazelcastInstance.class); + Assertions.assertThat(repository).isNotNull(); + Assertions.assertThat(hazelcastInstance).isNotNull(); + Assertions.assertThat(ReflectionTestUtils.getField(repository, "sessions")) + .isEqualTo(QualifiedAndPrimaryHazelcastInstanceConfiguration.qualifiedHazelcastInstanceSessions); + } + + @Test + void namedHazelcastInstanceConfiguration() { + registerAndRefresh(NamedHazelcastInstanceConfiguration.class); + + ReactiveHazelcastSessionRepository repository = this.context.getBean(ReactiveHazelcastSessionRepository.class); + HazelcastInstance hazelcastInstance = this.context.getBean("hazelcastInstance", HazelcastInstance.class); + Assertions.assertThat(repository).isNotNull(); + Assertions.assertThat(hazelcastInstance).isNotNull(); + Assertions.assertThat(ReflectionTestUtils.getField(repository, "sessions")) + .isEqualTo(NamedHazelcastInstanceConfiguration.hazelcastInstanceSessions); + } + + @Test + void multipleHazelcastInstanceConfiguration() { + Assertions.assertThatExceptionOfType(BeanCreationException.class) + .isThrownBy(() -> registerAndRefresh(MultipleHazelcastInstanceConfiguration.class)) + .withMessageContaining("expected single matching bean but found 2"); + } + + @Test + void sessionRepositoryCustomizer() { + registerAndRefresh(SessionRepositoryCustomizerConfiguration.class); + ReactiveHazelcastSessionRepository sessionRepository = this.context + .getBean(ReactiveHazelcastSessionRepository.class); + Assertions.assertThat(sessionRepository).hasFieldOrPropertyWithValue("defaultMaxInactiveInterval", + MAX_INACTIVE_INTERVAL_IN_SECONDS); + } + + private void registerAndRefresh(Class... annotatedClasses) { + this.context.register(annotatedClasses); + this.context.refresh(); + } + + @Configuration + @EnableHazelcastWebSession + static class NoHazelcastInstanceConfiguration { + + } + + static class BaseConfiguration { + + @SuppressWarnings("unchecked") + static IMap defaultHazelcastInstanceSessions = Mockito.mock(IMap.class); + + @Bean + HazelcastInstance defaultHazelcastInstance() { + HazelcastInstance hazelcastInstance = Mockito.mock(HazelcastInstance.class); + BDDMockito.given(hazelcastInstance.getMap(ArgumentMatchers.anyString())) + .willReturn(defaultHazelcastInstanceSessions); + return hazelcastInstance; + } + + } + + @Configuration + @EnableHazelcastWebSession + static class DefaultConfiguration extends BaseConfiguration { + + } + + @Configuration + @EnableHazelcastWebSession(sessionMapName = MAP_NAME) + static class CustomSessionMapNameConfiguration extends BaseConfiguration { + + } + + @Configuration + static class CustomSessionMapNameSetConfiguration extends HazelcastWebSessionConfiguration { + + CustomSessionMapNameSetConfiguration() { + setSessionMapName(MAP_NAME); + } + + } + + @Configuration + static class CustomMaxInactiveIntervalInSecondsSetConfiguration extends HazelcastWebSessionConfiguration { + + CustomMaxInactiveIntervalInSecondsSetConfiguration() { + setMaxInactiveIntervalInSeconds(MAX_INACTIVE_INTERVAL_IN_SECONDS); + } + + } + + @Configuration + @EnableHazelcastWebSession(maxInactiveIntervalInSeconds = MAX_INACTIVE_INTERVAL_IN_SECONDS) + static class CustomMaxInactiveIntervalInSecondsConfiguration extends BaseConfiguration { + + } + + @EnableHazelcastWebSession(saveMode = SaveMode.ALWAYS) + static class CustomSaveModeExpressionAnnotationConfiguration { + + } + + @Configuration + static class CustomSaveModeExpressionSetterConfiguration extends HazelcastWebSessionConfiguration { + + CustomSaveModeExpressionSetterConfiguration() { + setSaveMode(SaveMode.ALWAYS); + } + + } + + @Configuration + @EnableHazelcastWebSession + static class QualifiedHazelcastInstanceConfiguration extends BaseConfiguration { + + @SuppressWarnings("unchecked") + static IMap qualifiedHazelcastInstanceSessions = Mockito.mock(IMap.class); + + @Bean + @SpringSessionHazelcastInstance + HazelcastInstance qualifiedHazelcastInstance() { + HazelcastInstance hazelcastInstance = Mockito.mock(HazelcastInstance.class); + BDDMockito.given(hazelcastInstance.getMap(ArgumentMatchers.anyString())) + .willReturn(qualifiedHazelcastInstanceSessions); + return hazelcastInstance; + } + + } + + @Configuration + @EnableHazelcastWebSession + static class PrimaryHazelcastInstanceConfiguration extends BaseConfiguration { + + @SuppressWarnings("unchecked") + static IMap primaryHazelcastInstanceSessions = Mockito.mock(IMap.class); + + @Bean + @Primary + HazelcastInstance primaryHazelcastInstance() { + HazelcastInstance hazelcastInstance = Mockito.mock(HazelcastInstance.class); + BDDMockito.given(hazelcastInstance.getMap(ArgumentMatchers.anyString())) + .willReturn(primaryHazelcastInstanceSessions); + return hazelcastInstance; + } + + } + + @Configuration + @EnableHazelcastWebSession + static class QualifiedAndPrimaryHazelcastInstanceConfiguration extends BaseConfiguration { + + @SuppressWarnings("unchecked") + static IMap qualifiedHazelcastInstanceSessions = Mockito.mock(IMap.class); + + @SuppressWarnings("unchecked") + static IMap primaryHazelcastInstanceSessions = Mockito.mock(IMap.class); + + @Bean + @SpringSessionHazelcastInstance + HazelcastInstance qualifiedHazelcastInstance() { + HazelcastInstance hazelcastInstance = Mockito.mock(HazelcastInstance.class); + BDDMockito.given(hazelcastInstance.getMap(ArgumentMatchers.anyString())) + .willReturn(qualifiedHazelcastInstanceSessions); + return hazelcastInstance; + } + + @Bean + @Primary + HazelcastInstance primaryHazelcastInstance() { + HazelcastInstance hazelcastInstance = Mockito.mock(HazelcastInstance.class); + BDDMockito.given(hazelcastInstance.getMap(ArgumentMatchers.anyString())) + .willReturn(primaryHazelcastInstanceSessions); + return hazelcastInstance; + } + + } + + @Configuration + @EnableHazelcastWebSession + static class NamedHazelcastInstanceConfiguration extends BaseConfiguration { + + @SuppressWarnings("unchecked") + static IMap hazelcastInstanceSessions = Mockito.mock(IMap.class); + + @Bean + HazelcastInstance hazelcastInstance() { + HazelcastInstance hazelcastInstance = Mockito.mock(HazelcastInstance.class); + BDDMockito.given(hazelcastInstance.getMap(ArgumentMatchers.anyString())) + .willReturn(hazelcastInstanceSessions); + return hazelcastInstance; + } + + } + + @Configuration + @EnableHazelcastWebSession + static class MultipleHazelcastInstanceConfiguration extends BaseConfiguration { + + @SuppressWarnings("unchecked") + static IMap secondaryHazelcastInstanceSessions = Mockito.mock(IMap.class); + + @Bean + HazelcastInstance secondaryHazelcastInstance() { + HazelcastInstance hazelcastInstance = Mockito.mock(HazelcastInstance.class); + BDDMockito.given(hazelcastInstance.getMap(ArgumentMatchers.anyString())) + .willReturn(secondaryHazelcastInstanceSessions); + return hazelcastInstance; + } + + } + + @EnableHazelcastWebSession + static class SessionRepositoryCustomizerConfiguration extends BaseConfiguration { + + @Bean + @Order(0) + ReactiveSessionRepositoryCustomizer sessionRepositoryCustomizerOne() { + return (sessionRepository) -> sessionRepository.setDefaultMaxInactiveInterval(0); + } + + @Bean + @Order(1) + ReactiveSessionRepositoryCustomizer sessionRepositoryCustomizerTwo() { + return (sessionRepository) -> sessionRepository + .setDefaultMaxInactiveInterval(MAX_INACTIVE_INTERVAL_IN_SECONDS); + } + + } + +} diff --git a/spring-session-hazelcast/spring-session-hazelcast.gradle b/spring-session-hazelcast/spring-session-hazelcast.gradle index b7cd931f8..28d79fc69 100644 --- a/spring-session-hazelcast/spring-session-hazelcast.gradle +++ b/spring-session-hazelcast/spring-session-hazelcast.gradle @@ -9,6 +9,7 @@ dependencies { api "com.hazelcast:hazelcast" api "jakarta.annotation:jakarta.annotation-api" api "org.springframework:spring-context" + optional "org.springframework:spring-web" hazelcast4(project(path: ":hazelcast4", configuration: 'classesOnlyElements')) compileOnly(project(":hazelcast4")) diff --git a/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/server/EnableHazelcastWebSession.java b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/server/EnableHazelcastWebSession.java new file mode 100644 index 000000000..f6d3129e5 --- /dev/null +++ b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/server/EnableHazelcastWebSession.java @@ -0,0 +1,93 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.server; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.hazelcast.core.HazelcastInstance; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.session.MapSession; +import org.springframework.session.SaveMode; +import org.springframework.session.config.annotation.web.server.EnableSpringWebSession; +import org.springframework.web.server.session.WebSessionManager; + +/** + * Add this annotation to an {@code @Configuration} class to expose the + * {@link WebSessionManager} as a bean named {@code webSessionManager} and backed by + * Hazelcast. In order to leverage the annotation, a single {@link HazelcastInstance} must + * be provided. For example: + * + *
+ * @Configuration
+ * @EnableHazelcastWebSession
+ * public class HazelcastHttpSessionConfig {
+ *
+ *     @Bean
+ *     public HazelcastInstance embeddedHazelcast() {
+ *         Config hazelcastConfig = new Config();
+ *         return Hazelcast.newHazelcastInstance(hazelcastConfig);
+ *     }
+ *
+ * }
+ * 
+ * + * More advanced configurations can extend {@link HazelcastWebSessionConfiguration} + * instead. + * + * @author Tommy Ludwig + * @author Aleksandar Stojsavljevic + * @author Vedran Pavic + * @author Didier Loiseau + * @since 2.6.4 + * @see EnableSpringWebSession + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +@Import(HazelcastWebSessionConfiguration.class) +@Configuration(proxyBeanMethods = false) +public @interface EnableHazelcastWebSession { + + /** + * 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 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". + * @return the name of the Map to store the sessions in Hazelcast + */ + String sessionMapName() default "spring:session:sessions"; + + /** + * Save mode for the session. The default is {@link SaveMode#ON_SET_ATTRIBUTE}, which + * only saves changes made to session. + * @return the save mode + * @since 2.2.0 + */ + SaveMode saveMode() default SaveMode.ON_SET_ATTRIBUTE; + +} diff --git a/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/server/HazelcastWebSessionConfiguration.java b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/server/HazelcastWebSessionConfiguration.java new file mode 100644 index 000000000..5ecd3aea2 --- /dev/null +++ b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/server/HazelcastWebSessionConfiguration.java @@ -0,0 +1,130 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.server; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.hazelcast.core.HazelcastInstance; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +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.MapSession; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.SaveMode; +import org.springframework.session.config.ReactiveSessionRepositoryCustomizer; +import org.springframework.session.config.annotation.web.server.SpringWebSessionConfiguration; +import org.springframework.session.hazelcast.ReactiveHazelcastSessionRepository; +import org.springframework.session.hazelcast.config.annotation.SpringSessionHazelcastInstance; +import org.springframework.util.StringUtils; +import org.springframework.web.server.session.WebSessionManager; + +/** + * Exposes the {@link WebSessionManager} as a bean named {@code webSessionManager}. In + * order to use this a single {@link HazelcastInstance} must be exposed as a Bean. + * + * @author Tommy Ludwig + * @author Vedran Pavic + * @author Didier Loiseau + * @since 2.6.4 + * @see EnableHazelcastWebSession + */ +@Configuration(proxyBeanMethods = false) +public class HazelcastWebSessionConfiguration extends SpringWebSessionConfiguration implements ImportAware { + + private Integer maxInactiveIntervalInSeconds = MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS; + + private String sessionMapName = ReactiveHazelcastSessionRepository.DEFAULT_SESSION_MAP_NAME; + + private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE; + + private HazelcastInstance hazelcastInstance; + + private ApplicationEventPublisher applicationEventPublisher; + + private List> sessionRepositoryCustomizers; + + @Bean + public ReactiveSessionRepository sessionRepository() { + ReactiveHazelcastSessionRepository sessionRepository = new ReactiveHazelcastSessionRepository( + this.hazelcastInstance); + sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher); + if (StringUtils.hasText(this.sessionMapName)) { + sessionRepository.setSessionMapName(this.sessionMapName); + } + sessionRepository.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds); + sessionRepository.setSaveMode(this.saveMode); + this.sessionRepositoryCustomizers + .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository)); + return sessionRepository; + } + + public void setMaxInactiveIntervalInSeconds(int maxInactiveIntervalInSeconds) { + this.maxInactiveIntervalInSeconds = maxInactiveIntervalInSeconds; + } + + public void setSessionMapName(String sessionMapName) { + this.sessionMapName = sessionMapName; + } + + public void setSaveMode(SaveMode saveMode) { + this.saveMode = saveMode; + } + + @Autowired + public void setHazelcastInstance( + @SpringSessionHazelcastInstance ObjectProvider springSessionHazelcastInstance, + ObjectProvider hazelcastInstance) { + HazelcastInstance hazelcastInstanceToUse = springSessionHazelcastInstance.getIfAvailable(); + if (hazelcastInstanceToUse == null) { + hazelcastInstanceToUse = hazelcastInstance.getObject(); + } + this.hazelcastInstance = hazelcastInstanceToUse; + } + + @Autowired + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.applicationEventPublisher = applicationEventPublisher; + } + + @Autowired(required = false) + public void setSessionRepositoryCustomizer( + ObjectProvider> sessionRepositoryCustomizers) { + this.sessionRepositoryCustomizers = sessionRepositoryCustomizers.orderedStream().collect(Collectors.toList()); + } + + @Override + public void setImportMetadata(AnnotationMetadata importMetadata) { + Map attributeMap = importMetadata + .getAnnotationAttributes(EnableHazelcastWebSession.class.getName()); + AnnotationAttributes attributes = AnnotationAttributes.fromMap(attributeMap); + this.maxInactiveIntervalInSeconds = attributes.getNumber("maxInactiveIntervalInSeconds"); + String sessionMapNameValue = attributes.getString("sessionMapName"); + if (StringUtils.hasText(sessionMapNameValue)) { + this.sessionMapName = sessionMapNameValue; + } + this.saveMode = attributes.getEnum("saveMode"); + } + +}