diff --git a/spring-session-core/src/main/java/org/springframework/session/MapReactorSessionRepository.java b/spring-session-core/src/main/java/org/springframework/session/MapReactorSessionRepository.java new file mode 100644 index 00000000..a2b4bc0b --- /dev/null +++ b/spring-session-core/src/main/java/org/springframework/session/MapReactorSessionRepository.java @@ -0,0 +1,150 @@ +/* + * Copyright 2014-2017 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; + +import org.springframework.session.events.SessionDeletedEvent; +import org.springframework.session.events.SessionExpiredEvent; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A {@link SessionRepository} backed by a {@link Map} and that uses a + * {@link MapSession}. By default a {@link ConcurrentHashMap} is + * used, but a custom {@link Map} can be injected to use distributed maps + * provided by NoSQL stores like Redis and Hazelcast. + * + *

+ * The implementation does NOT support firing {@link SessionDeletedEvent} or + * {@link SessionExpiredEvent}. + *

+ * + * @author Rob Winch + * @since 2.0 + */ +public class MapReactorSessionRepository implements ReactorSessionRepository { + /** + * If non-null, this value is used to override + * {@link Session#setMaxInactiveInterval(Duration)}. + */ + private Integer defaultMaxInactiveInterval; + + private final Map sessions; + + /** + * Creates an instance backed by a {@link ConcurrentHashMap}. + */ + public MapReactorSessionRepository() { + this(new ConcurrentHashMap<>()); + } + + /** + * Creates a new instance backed by the provided {@link Map}. This allows + * injecting a distributed {@link Map}. + * + * @param sessions the {@link Map} to use. Cannot be null. + */ + public MapReactorSessionRepository(Map sessions) { + if (sessions == null) { + throw new IllegalArgumentException("sessions cannot be null"); + } + this.sessions = sessions; + } + + /** + * Creates a new instance backed by the provided {@link Map}. This allows + * injecting a distributed {@link Map}. + * + * @param sessions the {@link Map} to use. Cannot be null. + */ + public MapReactorSessionRepository(Session... sessions) { + if (sessions == null) { + throw new IllegalArgumentException("sessions cannot be null"); + } + this.sessions = new ConcurrentHashMap<>(); + for(Session session : sessions) { + this.performSave(session); + } + } + + /** + * Creates a new instance backed by the provided {@link Map}. This allows + * injecting a distributed {@link Map}. + * + * @param sessions the {@link Map} to use. Cannot be null. + */ + public MapReactorSessionRepository(Iterable sessions) { + if (sessions == null) { + throw new IllegalArgumentException("sessions cannot be null"); + } + this.sessions = new ConcurrentHashMap<>(); + for(Session session : sessions) { + this.performSave(session); + } + } + + /** + * If non-null, this value is used to override + * {@link Session#setMaxInactiveInterval(Duration)}. + * @param defaultMaxInactiveInterval the number of seconds that the {@link Session} + * should be kept alive between client requests. + */ + public void setDefaultMaxInactiveInterval(int defaultMaxInactiveInterval) { + this.defaultMaxInactiveInterval = Integer.valueOf(defaultMaxInactiveInterval); + } + + public Mono save(Session session) { + return Mono.fromRunnable(() -> performSave(session)); + } + + private void performSave(Session session) { + this.sessions.put(session.getId(), new MapSession(session)); + } + + public Mono findById(String id) { + return Mono.defer(() -> { + Session saved = this.sessions.get(id); + if (saved == null) { + return Mono.empty(); + } + if (saved.isExpired()) { + delete(saved.getId()); + return Mono.empty(); + } + return Mono.just(new MapSession(saved)); + }); + } + + public Mono delete(String id) { + return Mono.fromRunnable(() -> this.sessions.remove(id)); + } + + public Mono createSession() { + return Mono.defer(() -> { + Session result = new MapSession(); + if (this.defaultMaxInactiveInterval != null) { + result.setMaxInactiveInterval( + Duration.ofSeconds(this.defaultMaxInactiveInterval)); + } + return Mono.just(result); + }); + } +} diff --git a/spring-session-core/src/test/java/org/springframework/session/MapReactorSessionRepositoryTests.java b/spring-session-core/src/test/java/org/springframework/session/MapReactorSessionRepositoryTests.java new file mode 100644 index 00000000..3da5b976 --- /dev/null +++ b/spring-session-core/src/test/java/org/springframework/session/MapReactorSessionRepositoryTests.java @@ -0,0 +1,148 @@ +/* + * Copyright 2014-2017 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; + +import org.junit.Before; +import org.junit.Test; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Rob Winch + * @since 2.0 + */ +public class MapReactorSessionRepositoryTests { + MapReactorSessionRepository repository; + + MapSession session; + + @Before + public void setup() { + this.repository = new MapReactorSessionRepository(); + this.session = new MapSession("session-id"); + } + + @Test + public void constructorVarargsThenFound() { + this.repository = new MapReactorSessionRepository(this.session); + + Session findByIdSession = this.repository.findById(this.session.getId()).block(); + assertThat(findByIdSession).isNotNull(); + assertThat(findByIdSession.getId()).isEqualTo(this.session.getId()); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorVarargsWhenNullThenThrowsIllegalArgumentException() { + Session[] sessions = null; + new MapReactorSessionRepository(sessions); + } + + @Test + public void constructorIterableThenFound() { + this.repository = new MapReactorSessionRepository(Arrays.asList(this.session)); + + Session findByIdSession = this.repository.findById(this.session.getId()).block(); + assertThat(findByIdSession).isNotNull(); + assertThat(findByIdSession.getId()).isEqualTo(this.session.getId()); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorIterableWhenNullThenThrowsIllegalArgumentException() { + Iterable sessions = null; + new MapReactorSessionRepository(sessions); + } + + @Test + public void constructorMapThenFound() { + Map sessions = new HashMap<>(); + sessions.put(this.session.getId(), this.session); + this.repository = new MapReactorSessionRepository(sessions); + + Session findByIdSession = this.repository.findById(this.session.getId()).block(); + assertThat(findByIdSession).isNotNull(); + assertThat(findByIdSession.getId()).isEqualTo(this.session.getId()); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorMapWhenNullThenThrowsIllegalArgumentException() { + Map sessions = null; + new MapReactorSessionRepository(sessions); + } + + @Test + public void saveWhenNoSubscribersThenNotFound() { + this.repository.save(this.session); + + assertThat(this.repository.findById(this.session.getId()).block()).isNull(); + } + + @Test + public void saveWhenSubscriberThenFound() { + this.repository.save(this.session).block(); + + Session findByIdSession = this.repository.findById(this.session.getId()).block(); + assertThat(findByIdSession).isNotNull(); + assertThat(findByIdSession.getId()).isEqualTo(this.session.getId()); + } + + @Test + public void findByIdWhenNotExpiredThenFound() { + this.repository = new MapReactorSessionRepository(this.session); + + Session findByIdSession = this.repository.findById(this.session.getId()).block(); + assertThat(findByIdSession).isNotNull(); + assertThat(findByIdSession.getId()).isEqualTo(this.session.getId()); + } + + @Test + public void findByIdWhenExpiredThenEmpty() { + this.session.setMaxInactiveInterval(Duration.ofSeconds(1)); + this.session.setLastAccessedTime(Instant.now().minus(5, ChronoUnit.MINUTES)); + this.repository = new MapReactorSessionRepository(Arrays.asList(this.session)); + + assertThat(this.repository.findById(this.session.getId()).block()).isNull(); + } + + @Test + public void createSessionWhenDefaultMaxInactiveIntervalThenDefaultMaxInactiveInterval() { + Session session = this.repository.createSession().block(); + + assertThat(session).isInstanceOf(MapSession.class); + assertThat(session.getMaxInactiveInterval()) + .isEqualTo(new MapSession().getMaxInactiveInterval()); + } + + @Test + public void createSessionWhenCustomMaxInactiveIntervalThenCustomMaxInactiveInterval() { + final Duration expectedMaxInterval = new MapSession().getMaxInactiveInterval() + .plusSeconds(10); + this.repository.setDefaultMaxInactiveInterval( + (int) expectedMaxInterval.getSeconds()); + + Session session = this.repository.createSession().block(); + + assertThat(session.getMaxInactiveInterval()) + .isEqualTo(expectedMaxInterval); + } +} \ No newline at end of file