From 3565246320e47ea5249fe9d03786fcc3b85d02f2 Mon Sep 17 00:00:00 2001 From: Vedran Pavic Date: Wed, 1 May 2019 19:57:57 +0200 Subject: [PATCH 1/3] Extract RedisSessionMapper --- ...ctiveRedisOperationsSessionRepository.java | 73 ++--------- .../RedisOperationsSessionRepository.java | 55 +++----- .../data/redis/RedisSessionMapper.java | 97 ++++++++++++++ ...RedisOperationsSessionRepositoryTests.java | 33 ++--- ...RedisOperationsSessionRepositoryTests.java | 73 +++++------ .../data/redis/RedisSessionMapperTests.java | 121 ++++++++++++++++++ 6 files changed, 290 insertions(+), 162 deletions(-) create mode 100644 spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionMapper.java create mode 100644 spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionMapperTests.java diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisOperationsSessionRepository.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisOperationsSessionRepository.java index be375f508..0901e75a5 100644 --- a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisOperationsSessionRepository.java +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisOperationsSessionRepository.java @@ -21,7 +21,6 @@ import java.util.HashMap; import java.util.Map; import java.util.Set; -import java.util.function.Function; import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; @@ -47,29 +46,6 @@ public class ReactiveRedisOperationsSessionRepository implements */ public static final String DEFAULT_NAMESPACE = "spring:session"; - /** - * The key in the Hash representing {@link Session#getCreationTime()}. - */ - static final String CREATION_TIME_KEY = "creationTime"; - - /** - * The key in the Hash representing {@link Session#getLastAccessedTime()}. - */ - static final String LAST_ACCESSED_TIME_KEY = "lastAccessedTime"; - - /** - * The key in the Hash representing {@link Session#getMaxInactiveInterval()} . - */ - static final String MAX_INACTIVE_INTERVAL_KEY = "maxInactiveInterval"; - - /** - * The prefix of the key used for session attributes. The suffix is the name of - * the session attribute. For example, if the session contained an attribute named - * attributeName, then there would be an entry in the hash named - * sessionAttr:attributeName that mapped to its value. - */ - static final String ATTRIBUTE_PREFIX = "sessionAttr:"; - private final ReactiveRedisOperations sessionRedisOperations; /** @@ -162,7 +138,7 @@ public Mono findById(String id) { return this.sessionRedisOperations.opsForHash().entries(sessionKey) .collectMap((e) -> e.getKey().toString(), Map.Entry::getValue) .filter((map) -> !map.isEmpty()) - .map(new SessionMapper(id)) + .map(new RedisSessionMapper(id)) .filter((session) -> !session.isExpired()) .map(RedisSession::new) .switchIfEmpty(Mono.defer(() -> deleteById(id).then(Mono.empty()))); @@ -177,7 +153,7 @@ public Mono deleteById(String id) { } private static String getAttributeKey(String attributeName) { - return ATTRIBUTE_PREFIX + attributeName; + return RedisSessionMapper.ATTRIBUTE_PREFIX + attributeName; } private String getSessionKey(String sessionId) { @@ -206,10 +182,12 @@ final class RedisSession implements Session { */ RedisSession() { this(new MapSession()); - this.delta.put(CREATION_TIME_KEY, getCreationTime().toEpochMilli()); - this.delta.put(MAX_INACTIVE_INTERVAL_KEY, + this.delta.put(RedisSessionMapper.CREATION_TIME_KEY, + getCreationTime().toEpochMilli()); + this.delta.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, (int) getMaxInactiveInterval().getSeconds()); - this.delta.put(LAST_ACCESSED_TIME_KEY, getLastAccessedTime().toEpochMilli()); + this.delta.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, + getLastAccessedTime().toEpochMilli()); this.isNew = true; this.flushImmediateIfNecessary(); } @@ -266,7 +244,8 @@ public Instant getCreationTime() { @Override public void setLastAccessedTime(Instant lastAccessedTime) { this.cached.setLastAccessedTime(lastAccessedTime); - putAndFlush(LAST_ACCESSED_TIME_KEY, getLastAccessedTime().toEpochMilli()); + putAndFlush(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, + getLastAccessedTime().toEpochMilli()); } @Override @@ -277,7 +256,7 @@ public Instant getLastAccessedTime() { @Override public void setMaxInactiveInterval(Duration interval) { this.cached.setMaxInactiveInterval(interval); - putAndFlush(MAX_INACTIVE_INTERVAL_KEY, + putAndFlush(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, (int) getMaxInactiveInterval().getSeconds()); } @@ -354,36 +333,4 @@ private Mono saveChangeSessionId() { } - private static final class SessionMapper - implements Function, MapSession> { - - private final String id; - - private SessionMapper(String id) { - this.id = id; - } - - @Override - public MapSession apply(Map map) { - MapSession session = new MapSession(this.id); - - session.setCreationTime( - Instant.ofEpochMilli((long) map.get(CREATION_TIME_KEY))); - session.setLastAccessedTime( - Instant.ofEpochMilli((long) map.get(LAST_ACCESSED_TIME_KEY))); - session.setMaxInactiveInterval( - Duration.ofSeconds((int) map.get(MAX_INACTIVE_INTERVAL_KEY))); - - map.forEach((name, value) -> { - if (name.startsWith(ATTRIBUTE_PREFIX)) { - session.setAttribute(name.substring(ATTRIBUTE_PREFIX.length()), - value); - } - }); - - return session; - } - - } - } diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java index 2be2796da..2201817ed 100644 --- a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java @@ -264,33 +264,6 @@ public class RedisOperationsSessionRepository implements */ public static final String DEFAULT_NAMESPACE = "spring:session"; - /** - * The key in the Hash representing - * {@link org.springframework.session.Session#getCreationTime()}. - */ - static final String CREATION_TIME_ATTR = "creationTime"; - - /** - * The key in the Hash representing - * {@link org.springframework.session.Session#getMaxInactiveInterval()} - * . - */ - static final String MAX_INACTIVE_ATTR = "maxInactiveInterval"; - - /** - * The key in the Hash representing - * {@link org.springframework.session.Session#getLastAccessedTime()}. - */ - static final String LAST_ACCESSED_ATTR = "lastAccessedTime"; - - /** - * The prefix of the key used for session attributes. The suffix is the name of - * the session attribute. For example, if the session contained an attribute named - * attributeName, then there would be an entry in the hash named - * sessionAttr:attributeName that mapped to its value. - */ - static final String SESSION_ATTR_PREFIX = "sessionAttr:"; - private int database = RedisOperationsSessionRepository.DEFAULT_DATABASE; /** @@ -480,17 +453,18 @@ private MapSession loadSession(String id, Map entries) { MapSession loaded = new MapSession(id); for (Map.Entry entry : entries.entrySet()) { String key = (String) entry.getKey(); - if (CREATION_TIME_ATTR.equals(key)) { + if (RedisSessionMapper.CREATION_TIME_KEY.equals(key)) { loaded.setCreationTime(Instant.ofEpochMilli((long) entry.getValue())); } - else if (MAX_INACTIVE_ATTR.equals(key)) { + else if (RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY.equals(key)) { loaded.setMaxInactiveInterval(Duration.ofSeconds((int) entry.getValue())); } - else if (LAST_ACCESSED_ATTR.equals(key)) { + else if (RedisSessionMapper.LAST_ACCESSED_TIME_KEY.equals(key)) { loaded.setLastAccessedTime(Instant.ofEpochMilli((long) entry.getValue())); } - else if (key.startsWith(SESSION_ATTR_PREFIX)) { - loaded.setAttribute(key.substring(SESSION_ATTR_PREFIX.length()), + else if (key.startsWith(RedisSessionMapper.ATTRIBUTE_PREFIX)) { + loaded.setAttribute( + key.substring(RedisSessionMapper.ATTRIBUTE_PREFIX.length()), entry.getValue()); } } @@ -689,7 +663,7 @@ private BoundHashOperations getSessionBoundHashOperation * @return the attribute key name */ static String getSessionAttrNameKey(String attributeName) { - return SESSION_ATTR_PREFIX + attributeName; + return RedisSessionMapper.ATTRIBUTE_PREFIX + attributeName; } /** @@ -719,9 +693,12 @@ final class RedisSession implements Session { RedisSession(Duration maxInactiveInterval) { this(new MapSession()); this.cached.setMaxInactiveInterval(maxInactiveInterval); - this.delta.put(CREATION_TIME_ATTR, getCreationTime().toEpochMilli()); - this.delta.put(MAX_INACTIVE_ATTR, (int) getMaxInactiveInterval().getSeconds()); - this.delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime().toEpochMilli()); + this.delta.put(RedisSessionMapper.CREATION_TIME_KEY, + getCreationTime().toEpochMilli()); + this.delta.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + (int) getMaxInactiveInterval().getSeconds()); + this.delta.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, + getLastAccessedTime().toEpochMilli()); this.isNew = true; } @@ -745,7 +722,8 @@ public void setNew(boolean isNew) { @Override public void setLastAccessedTime(Instant lastAccessedTime) { this.cached.setLastAccessedTime(lastAccessedTime); - this.putAndFlush(LAST_ACCESSED_ATTR, getLastAccessedTime().toEpochMilli()); + this.putAndFlush(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, + getLastAccessedTime().toEpochMilli()); } @Override @@ -780,7 +758,8 @@ public Instant getLastAccessedTime() { @Override public void setMaxInactiveInterval(Duration interval) { this.cached.setMaxInactiveInterval(interval); - this.putAndFlush(MAX_INACTIVE_ATTR, (int) getMaxInactiveInterval().getSeconds()); + this.putAndFlush(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + (int) getMaxInactiveInterval().getSeconds()); } @Override diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionMapper.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionMapper.java new file mode 100644 index 000000000..476d03d38 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionMapper.java @@ -0,0 +1,97 @@ +/* + * Copyright 2014-2019 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.data.redis; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.function.Function; + +import org.springframework.session.MapSession; +import org.springframework.session.Session; +import org.springframework.util.Assert; + +/** + * A {@link Function} that converts a {@link Map} representing Redis hash to a + * {@link MapSession}. + * + * @author Vedran Pavic + * @since 2.2.0 + */ +final class RedisSessionMapper implements Function, MapSession> { + + /** + * The key in the hash representing {@link Session#getCreationTime()}. + */ + static final String CREATION_TIME_KEY = "creationTime"; + + /** + * The key in the hash representing {@link Session#getLastAccessedTime()}. + */ + static final String LAST_ACCESSED_TIME_KEY = "lastAccessedTime"; + + /** + * The key in the hash representing {@link Session#getMaxInactiveInterval()}. + */ + static final String MAX_INACTIVE_INTERVAL_KEY = "maxInactiveInterval"; + + /** + * The prefix of the key in the hash used for session attributes. For example, if the + * session contained an attribute named {@code attributeName}, then there would be an + * entry in the hash named {@code sessionAttr:attributeName} that mapped to its value. + */ + static final String ATTRIBUTE_PREFIX = "sessionAttr:"; + + private final String sessionId; + + RedisSessionMapper(String sessionId) { + Assert.hasText(sessionId, "sessionId must not be empty"); + this.sessionId = sessionId; + } + + @Override + public MapSession apply(Map map) { + Assert.notEmpty(map, "map must not be empty"); + MapSession session = new MapSession(this.sessionId); + Long creationTime = (Long) map.get(CREATION_TIME_KEY); + if (creationTime == null) { + handleMissingKey(CREATION_TIME_KEY); + } + session.setCreationTime(Instant.ofEpochMilli(creationTime)); + Long lastAccessedTime = (Long) map.get(LAST_ACCESSED_TIME_KEY); + if (lastAccessedTime == null) { + handleMissingKey(LAST_ACCESSED_TIME_KEY); + } + session.setLastAccessedTime(Instant.ofEpochMilli(lastAccessedTime)); + Integer maxInactiveInterval = (Integer) map.get(MAX_INACTIVE_INTERVAL_KEY); + if (maxInactiveInterval == null) { + handleMissingKey(MAX_INACTIVE_INTERVAL_KEY); + } + session.setMaxInactiveInterval(Duration.ofSeconds(maxInactiveInterval)); + map.forEach((name, value) -> { + if (name.startsWith(ATTRIBUTE_PREFIX)) { + session.setAttribute(name.substring(ATTRIBUTE_PREFIX.length()), value); + } + }); + return session; + } + + private static void handleMissingKey(String key) { + throw new IllegalStateException(key + " key must not be null"); + } + +} diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisOperationsSessionRepositoryTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisOperationsSessionRepositoryTests.java index ab884701b..472e939f5 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisOperationsSessionRepositoryTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisOperationsSessionRepositoryTests.java @@ -167,15 +167,12 @@ public void saveNewSession() { Map delta = this.delta.getAllValues().get(0); assertThat(delta.size()).isEqualTo(3); - assertThat(delta.get(ReactiveRedisOperationsSessionRepository.CREATION_TIME_KEY)) + assertThat(delta.get(RedisSessionMapper.CREATION_TIME_KEY)) .isEqualTo(newSession.getCreationTime().toEpochMilli()); - assertThat(delta - .get(ReactiveRedisOperationsSessionRepository.MAX_INACTIVE_INTERVAL_KEY)) - .isEqualTo( - (int) newSession.getMaxInactiveInterval().getSeconds()); - assertThat(delta - .get(ReactiveRedisOperationsSessionRepository.LAST_ACCESSED_TIME_KEY)) - .isEqualTo(newSession.getLastAccessedTime().toEpochMilli()); + assertThat(delta.get(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY)) + .isEqualTo((int) newSession.getMaxInactiveInterval().getSeconds()); + assertThat(delta.get(RedisSessionMapper.LAST_ACCESSED_TIME_KEY)) + .isEqualTo(newSession.getLastAccessedTime().toEpochMilli()); } @Test @@ -214,7 +211,7 @@ public void saveLastAccessChanged() { verifyZeroInteractions(this.hashOperations); assertThat(this.delta.getAllValues().get(0)) - .isEqualTo(map(RedisOperationsSessionRepository.LAST_ACCESSED_ATTR, + .isEqualTo(map(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, session.getLastAccessedTime().toEpochMilli())); } @@ -316,16 +313,14 @@ public void getSessionFound() { expected.setLastAccessedTime(Instant.now().minusSeconds(60)); expected.setAttribute(attribute1, "test"); expected.setAttribute(attribute2, null); - Map map = map( - ReactiveRedisOperationsSessionRepository.ATTRIBUTE_PREFIX + attribute1, + Map map = map(RedisSessionMapper.ATTRIBUTE_PREFIX + attribute1, expected.getAttribute(attribute1), - ReactiveRedisOperationsSessionRepository.ATTRIBUTE_PREFIX + attribute2, - expected.getAttribute(attribute2), - ReactiveRedisOperationsSessionRepository.CREATION_TIME_KEY, + RedisSessionMapper.ATTRIBUTE_PREFIX + attribute2, + expected.getAttribute(attribute2), RedisSessionMapper.CREATION_TIME_KEY, expected.getCreationTime().toEpochMilli(), - ReactiveRedisOperationsSessionRepository.MAX_INACTIVE_INTERVAL_KEY, + RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, (int) expected.getMaxInactiveInterval().getSeconds(), - ReactiveRedisOperationsSessionRepository.LAST_ACCESSED_TIME_KEY, + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, expected.getLastAccessedTime().toEpochMilli()); given(this.hashOperations.entries(anyString())) .willReturn(Flux.fromIterable(map.entrySet())); @@ -360,9 +355,9 @@ public void getSessionFound() { @SuppressWarnings("unchecked") public void getSessionExpired() { given(this.redisOperations.opsForHash()).willReturn(this.hashOperations); - Map map = map(ReactiveRedisOperationsSessionRepository.CREATION_TIME_KEY, 0L, - ReactiveRedisOperationsSessionRepository.MAX_INACTIVE_INTERVAL_KEY, 1, - ReactiveRedisOperationsSessionRepository.LAST_ACCESSED_TIME_KEY, + Map map = map(RedisSessionMapper.CREATION_TIME_KEY, 0L, + RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 1, + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, Instant.now().minus(5, ChronoUnit.MINUTES).toEpochMilli()); given(this.hashOperations.entries(anyString())) .willReturn(Flux.fromIterable(map.entrySet())); diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryTests.java index 43b584fa8..ede229b22 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryTests.java @@ -190,14 +190,12 @@ public void saveNewSession() { Map delta = getDelta(); assertThat(delta.size()).isEqualTo(3); - Object creationTime = delta - .get(RedisOperationsSessionRepository.CREATION_TIME_ATTR); + Object creationTime = delta.get(RedisSessionMapper.CREATION_TIME_KEY); assertThat(creationTime).isEqualTo(session.getCreationTime().toEpochMilli()); - assertThat(delta.get(RedisOperationsSessionRepository.MAX_INACTIVE_ATTR)) - .isEqualTo((int) Duration - .ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS) + assertThat(delta.get(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY)).isEqualTo( + (int) Duration.ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS) .getSeconds()); - assertThat(delta.get(RedisOperationsSessionRepository.LAST_ACCESSED_ATTR)) + assertThat(delta.get(RedisSessionMapper.LAST_ACCESSED_TIME_KEY)) .isEqualTo(session.getCreationTime().toEpochMilli()); } @@ -283,9 +281,8 @@ public void saveLastAccessChanged() { this.redisRepository.save(session); - assertThat(getDelta()) - .isEqualTo(map(RedisOperationsSessionRepository.LAST_ACCESSED_ATTR, - session.getLastAccessedTime().toEpochMilli())); + assertThat(getDelta()).isEqualTo(map(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, + session.getLastAccessedTime().toEpochMilli())); } @Test @@ -364,12 +361,11 @@ public void delete() { given(this.redisOperations.boundSetOps(anyString())) .willReturn(this.boundSetOperations); Map map = map(RedisOperationsSessionRepository.getSessionAttrNameKey(attrName), - expected.getAttribute(attrName), - RedisOperationsSessionRepository.CREATION_TIME_ATTR, + expected.getAttribute(attrName), RedisSessionMapper.CREATION_TIME_KEY, expected.getCreationTime().toEpochMilli(), - RedisOperationsSessionRepository.MAX_INACTIVE_ATTR, + RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, (int) expected.getMaxInactiveInterval().getSeconds(), - RedisOperationsSessionRepository.LAST_ACCESSED_ATTR, + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, expected.getLastAccessedTime().toEpochMilli()); given(this.boundHashOperations.entries()).willReturn(map); given(this.redisOperations.boundSetOps(anyString())) @@ -378,7 +374,7 @@ public void delete() { String id = expected.getId(); this.redisRepository.deleteById(id); - assertThat(getDelta().get(RedisOperationsSessionRepository.MAX_INACTIVE_ATTR)) + assertThat(getDelta().get(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY)) .isEqualTo(0); verify(this.redisOperations, atLeastOnce()).delete(getKey("expires:" + id)); verify(this.redisOperations, never()).boundValueOps(getKey("expires:" + id)); @@ -418,12 +414,11 @@ public void getSessionFound() { Map map = map(RedisOperationsSessionRepository.getSessionAttrNameKey(attribute1), expected.getAttribute(attribute1), RedisOperationsSessionRepository.getSessionAttrNameKey(attribute2), - expected.getAttribute(attribute2), - RedisOperationsSessionRepository.CREATION_TIME_ATTR, + expected.getAttribute(attribute2), RedisSessionMapper.CREATION_TIME_KEY, expected.getCreationTime().toEpochMilli(), - RedisOperationsSessionRepository.MAX_INACTIVE_ATTR, + RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, (int) expected.getMaxInactiveInterval().getSeconds(), - RedisOperationsSessionRepository.LAST_ACCESSED_ATTR, + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, expected.getLastAccessedTime().toEpochMilli()); given(this.boundHashOperations.entries()).willReturn(map); @@ -447,8 +442,8 @@ public void getSessionExpired() { String expiredId = "expired-id"; given(this.redisOperations.boundHashOps(getKey(expiredId))) .willReturn(this.boundHashOperations); - Map map = map(RedisOperationsSessionRepository.MAX_INACTIVE_ATTR, 1, - RedisOperationsSessionRepository.LAST_ACCESSED_ATTR, + Map map = map(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 1, + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, Instant.now().minus(5, ChronoUnit.MINUTES).toEpochMilli()); given(this.boundHashOperations.entries()).willReturn(map); @@ -464,8 +459,8 @@ public void findByPrincipalNameExpired() { .willReturn(Collections.singleton(expiredId)); given(this.redisOperations.boundHashOps(getKey(expiredId))) .willReturn(this.boundHashOperations); - Map map = map(RedisOperationsSessionRepository.MAX_INACTIVE_ATTR, 1, - RedisOperationsSessionRepository.LAST_ACCESSED_ATTR, + Map map = map(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 1, + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, Instant.now().minus(5, ChronoUnit.MINUTES).toEpochMilli()); given(this.boundHashOperations.entries()).willReturn(map); @@ -486,11 +481,9 @@ public void findByPrincipalName() { .willReturn(Collections.singleton(sessionId)); given(this.redisOperations.boundHashOps(getKey(sessionId))) .willReturn(this.boundHashOperations); - Map map = map(RedisOperationsSessionRepository.CREATION_TIME_ATTR, - createdTime.toEpochMilli(), - RedisOperationsSessionRepository.MAX_INACTIVE_ATTR, - (int) maxInactive.getSeconds(), - RedisOperationsSessionRepository.LAST_ACCESSED_ATTR, + Map map = map(RedisSessionMapper.CREATION_TIME_KEY, createdTime.toEpochMilli(), + RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + (int) maxInactive.getSeconds(), RedisSessionMapper.LAST_ACCESSED_TIME_KEY, lastAccessed.toEpochMilli()); given(this.boundHashOperations.entries()).willReturn(map); @@ -572,8 +565,8 @@ public void onMessageDeletedSessionFound() { String deletedId = "deleted-id"; given(this.redisOperations.boundHashOps(getKey(deletedId))) .willReturn(this.boundHashOperations); - Map map = map(RedisOperationsSessionRepository.MAX_INACTIVE_ATTR, 0, - RedisOperationsSessionRepository.LAST_ACCESSED_ATTR, + Map map = map(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 0, + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5)); given(this.boundHashOperations.entries()).willReturn(map); @@ -625,8 +618,8 @@ public void onMessageExpiredSessionFound() { String expiredId = "expired-id"; given(this.redisOperations.boundHashOps(getKey(expiredId))) .willReturn(this.boundHashOperations); - Map map = map(RedisOperationsSessionRepository.MAX_INACTIVE_ATTR, 1, - RedisOperationsSessionRepository.LAST_ACCESSED_ATTR, + Map map = map(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 1, + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5)); given(this.boundHashOperations.entries()).willReturn(map); @@ -753,14 +746,12 @@ public void flushModeImmediateCreate() { Map delta = getDelta(); assertThat(delta.size()).isEqualTo(3); - Object creationTime = delta - .get(RedisOperationsSessionRepository.CREATION_TIME_ATTR); + Object creationTime = delta.get(RedisSessionMapper.CREATION_TIME_KEY); assertThat(creationTime).isEqualTo(session.getCreationTime().toEpochMilli()); - assertThat(delta.get(RedisOperationsSessionRepository.MAX_INACTIVE_ATTR)) - .isEqualTo((int) Duration - .ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS) + assertThat(delta.get(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY)).isEqualTo( + (int) Duration.ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS) .getSeconds()); - assertThat(delta.get(RedisOperationsSessionRepository.LAST_ACCESSED_ATTR)) + assertThat(delta.get(RedisSessionMapper.LAST_ACCESSED_TIME_KEY)) .isEqualTo(session.getCreationTime().toEpochMilli()); } @@ -777,8 +768,7 @@ public void flushModeImmediateCreateWithCustomMaxInactiveInterval() { this.redisRepository.createSession(); Map delta = getDelta(); assertThat(delta.size()).isEqualTo(3); - assertThat(delta.get(RedisOperationsSessionRepository.MAX_INACTIVE_ATTR)) - .isEqualTo(60); + assertThat(delta.get(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY)).isEqualTo(60); } @Test @@ -858,9 +848,8 @@ public void flushModeSetLastAccessedTime() { Map delta = getDelta(2); assertThat(delta.size()).isEqualTo(1); - assertThat(delta) - .isEqualTo(map(RedisOperationsSessionRepository.LAST_ACCESSED_ATTR, - session.getLastAccessedTime().toEpochMilli())); + assertThat(delta).isEqualTo(map(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, + session.getLastAccessedTime().toEpochMilli())); } @Test diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionMapperTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionMapperTests.java new file mode 100644 index 000000000..5640c6c34 --- /dev/null +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionMapperTests.java @@ -0,0 +1,121 @@ +/* + * Copyright 2014-2019 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.data.redis; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.session.MapSession; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link RedisSessionMapper}. + * + * @author Vedran Pavic + */ +class RedisSessionMapperTests { + + private RedisSessionMapper mapper; + + @BeforeEach + void setUp() { + this.mapper = new RedisSessionMapper("id"); + } + + @Test + void constructor_NullId_ShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new RedisSessionMapper(null)) + .withMessage("sessionId must not be empty"); + } + + @Test + void constructor_EmptyId_ShouldThrowException() { + assertThatIllegalArgumentException().isThrownBy(() -> new RedisSessionMapper(" ")) + .withMessage("sessionId must not be empty"); + } + + @Test + void apply_NullMap_ShouldThrowException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.mapper.apply(null)) + .withMessage("map must not be empty"); + } + + @Test + void apply_EmptyMap_ShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.mapper.apply(Collections.emptyMap())) + .withMessage("map must not be empty"); + } + + @Test + void apply_MapWithoutCreationTime_ShouldThrowException() { + Map sessionMap = new HashMap<>(); + sessionMap.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, 0L); + sessionMap.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 1800); + assertThatIllegalStateException().isThrownBy(() -> this.mapper.apply(sessionMap)) + .withMessage( + RedisSessionMapper.CREATION_TIME_KEY + " key must not be null"); + } + + @Test + void apply_MapWithoutLastAccessedTime_ShouldThrowException() { + Map sessionMap = new HashMap<>(); + sessionMap.put(RedisSessionMapper.CREATION_TIME_KEY, 0L); + sessionMap.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 1800); + assertThatIllegalStateException().isThrownBy(() -> this.mapper.apply(sessionMap)) + .withMessage(RedisSessionMapper.LAST_ACCESSED_TIME_KEY + + " key must not be null"); + } + + @Test + void apply_MapWithoutMaxInactiveInterval_ShouldThrowException() { + Map sessionMap = new HashMap<>(); + sessionMap.put(RedisSessionMapper.CREATION_TIME_KEY, 0L); + sessionMap.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, 0L); + assertThatIllegalStateException().isThrownBy(() -> this.mapper.apply(sessionMap)) + .withMessage(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY + + " key must not be null"); + } + + @Test + void apply_ValidMap_ShouldReturnSession() { + Map sessionMap = new HashMap<>(); + sessionMap.put(RedisSessionMapper.CREATION_TIME_KEY, 0L); + sessionMap.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, 0L); + sessionMap.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 1800); + sessionMap.put(RedisSessionMapper.ATTRIBUTE_PREFIX + "existing", "value"); + sessionMap.put(RedisSessionMapper.ATTRIBUTE_PREFIX + "missing", null); + MapSession session = this.mapper.apply(sessionMap); + assertThat(session.getId()).isEqualTo("id"); + assertThat(session.getCreationTime()).isEqualTo(Instant.ofEpochMilli(0)); + assertThat(session.getLastAccessedTime()).isEqualTo(Instant.ofEpochMilli(0)); + assertThat(session.getMaxInactiveInterval()).isEqualTo(Duration.ofMinutes(30)); + assertThat(session.getAttributeNames()).hasSize(1); + assertThat((String) session.getAttribute("existing")).isEqualTo("value"); + } + +} From d19e1f8b67bac505a727b1c8515567d2ca52d506 Mon Sep 17 00:00:00 2001 From: Vedran Pavic Date: Wed, 1 May 2019 19:58:07 +0200 Subject: [PATCH 2/3] Add simple Redis SessionRepository implementation --- ...edisOperationsSessionRepositoryITests.java | 244 +++++++++++++ ...impleRedisOperationsSessionRepository.java | 297 +++++++++++++++ ...RedisOperationsSessionRepositoryTests.java | 343 ++++++++++++++++++ 3 files changed, 884 insertions(+) create mode 100644 spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryITests.java create mode 100644 spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepository.java create mode 100644 spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryTests.java diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryITests.java new file mode 100644 index 000000000..9b18edb4d --- /dev/null +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryITests.java @@ -0,0 +1,244 @@ +/* + * Copyright 2014-2019 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.data.redis; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.UUID; + +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.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.session.MapSession; +import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession; +import org.springframework.session.data.redis.SimpleRedisOperationsSessionRepository.RedisSession; +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; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Integration tests for {@link SimpleRedisOperationsSessionRepository}. + * + * @author Vedran Pavic + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +@WebAppConfiguration +class SimpleRedisOperationsSessionRepositoryITests extends AbstractRedisITests { + + @Autowired + private SimpleRedisOperationsSessionRepository sessionRepository; + + @Test + void save_NewSession_ShouldSaveSession() { + RedisSession session = createAndSaveSession(Instant.now()); + assertThat(session.getMaxInactiveInterval()).isEqualTo( + Duration.ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS)); + assertThat(session.getAttributeNames()) + .isEqualTo(Collections.singleton("attribute1")); + assertThat(session.getAttribute("attribute1")).isEqualTo("value1"); + } + + @Test + void save_LastAccessedTimeInPast_ShouldExpireSession() { + assertThat(createAndSaveSession(Instant.EPOCH)).isNull(); + } + + @Test + void save_DeletedSession_ShouldThrowException() { + RedisSession session = createAndSaveSession(Instant.now()); + this.sessionRepository.deleteById(session.getId()); + assertThatIllegalStateException() + .isThrownBy(() -> this.sessionRepository.save(session)) + .withMessage("Session was invalidated"); + } + + @Test + void save_ConcurrentUpdates_ShouldSaveSession() { + RedisSession copy1 = createAndSaveSession(Instant.now()); + String sessionId = copy1.getId(); + RedisSession copy2 = this.sessionRepository.findById(sessionId); + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + updateSession(copy1, now.plusSeconds(1L), "attribute2", "value2"); + this.sessionRepository.save(copy1); + updateSession(copy2, now.plusSeconds(2L), "attribute3", "value3"); + this.sessionRepository.save(copy2); + RedisSession session = this.sessionRepository.findById(sessionId); + assertThat(session.getLastAccessedTime()).isEqualTo(now.plusSeconds(2L)); + assertThat(session.getAttributeNames()).hasSize(3); + assertThat(session.getAttribute("attribute1")).isEqualTo("value1"); + assertThat(session.getAttribute("attribute2")).isEqualTo("value2"); + assertThat(session.getAttribute("attribute3")).isEqualTo("value3"); + } + + @Test + void save_ChangeSessionIdAndUpdateAttribute_ShouldChangeSessionId() { + RedisSession session = createAndSaveSession(Instant.now()); + String originalSessionId = session.getId(); + updateSession(session, Instant.now(), "attribute1", "value2"); + String newSessionId = session.changeSessionId(); + this.sessionRepository.save(session); + RedisSession loaded = this.sessionRepository.findById(newSessionId); + assertThat(loaded).isNotNull(); + assertThat(loaded.getAttributeNames()).hasSize(1); + assertThat(loaded.getAttribute("attribute1")).isEqualTo("value2"); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void save_OnlyChangeSessionId_ShouldChangeSessionId() { + RedisSession session = createAndSaveSession(Instant.now()); + String originalSessionId = session.getId(); + String newSessionId = session.changeSessionId(); + this.sessionRepository.save(session); + assertThat(this.sessionRepository.findById(newSessionId)).isNotNull(); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void save_ChangeSessionIdTwice_ShouldChangeSessionId() { + RedisSession session = createAndSaveSession(Instant.now()); + String originalSessionId = session.getId(); + updateSession(session, Instant.now(), "attribute1", "value2"); + String newSessionId1 = session.changeSessionId(); + updateSession(session, Instant.now(), "attribute1", "value3"); + String newSessionId2 = session.changeSessionId(); + this.sessionRepository.save(session); + assertThat(this.sessionRepository.findById(newSessionId1)).isNull(); + assertThat(this.sessionRepository.findById(newSessionId2)).isNotNull(); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void save_ChangeSessionIdOnNewSession_ShouldChangeSessionId() { + RedisSession session = this.sessionRepository.createSession(); + String originalSessionId = session.getId(); + updateSession(session, Instant.now(), "attribute1", "value1"); + String newSessionId = session.changeSessionId(); + this.sessionRepository.save(session); + assertThat(this.sessionRepository.findById(newSessionId)).isNotNull(); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void save_ChangeSessionIdSaveTwice_ShouldChangeSessionId() { + RedisSession session = createAndSaveSession(Instant.now()); + String originalSessionId; + originalSessionId = session.getId(); + updateSession(session, Instant.now(), "attribute1", "value1"); + String newSessionId = session.changeSessionId(); + this.sessionRepository.save(session); + this.sessionRepository.save(session); + assertThat(this.sessionRepository.findById(newSessionId)).isNotNull(); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void save_ChangeSessionIdOnDeletedSession_ShouldThrowException() { + RedisSession session = createAndSaveSession(Instant.now()); + String originalSessionId = session.getId(); + this.sessionRepository.deleteById(originalSessionId); + updateSession(session, Instant.now(), "attribute1", "value1"); + String newSessionId = session.changeSessionId(); + assertThatIllegalStateException() + .isThrownBy(() -> this.sessionRepository.save(session)) + .withMessage("Session was invalidated"); + assertThat(this.sessionRepository.findById(newSessionId)).isNull(); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void save_ChangeSessionIdConcurrent_ShouldThrowException() { + RedisSession copy1 = createAndSaveSession(Instant.now()); + String originalSessionId = copy1.getId(); + RedisSession copy2 = this.sessionRepository.findById(originalSessionId); + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + updateSession(copy1, now.plusSeconds(1L), "attribute2", "value2"); + String newSessionId1 = copy1.changeSessionId(); + this.sessionRepository.save(copy1); + updateSession(copy2, now.plusSeconds(2L), "attribute3", "value3"); + String newSessionId2 = copy2.changeSessionId(); + assertThatIllegalStateException() + .isThrownBy(() -> this.sessionRepository.save(copy2)) + .withMessage("Session was invalidated"); + assertThat(this.sessionRepository.findById(newSessionId1)).isNotNull(); + assertThat(this.sessionRepository.findById(newSessionId2)).isNull(); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void deleteById_ValidSession_ShouldDeleteSession() { + RedisSession session = createAndSaveSession(Instant.now()); + this.sessionRepository.deleteById(session.getId()); + assertThat(this.sessionRepository.findById(session.getId())).isNull(); + } + + @Test + void deleteById_DeletedSession_ShouldDoNothing() { + RedisSession session = createAndSaveSession(Instant.now()); + this.sessionRepository.deleteById(session.getId()); + this.sessionRepository.deleteById(session.getId()); + assertThat(this.sessionRepository.findById(session.getId())).isNull(); + } + + @Test + void deleteById_NonexistentSession_ShouldDoNothing() { + String sessionId = UUID.randomUUID().toString(); + this.sessionRepository.deleteById(sessionId); + assertThat(this.sessionRepository.findById(sessionId)).isNull(); + } + + private RedisSession createAndSaveSession(Instant lastAccessedTime) { + RedisSession session = this.sessionRepository.createSession(); + session.setLastAccessedTime(lastAccessedTime); + session.setAttribute("attribute1", "value1"); + this.sessionRepository.save(session); + return this.sessionRepository.findById(session.getId()); + } + + private static void updateSession(RedisSession session, Instant lastAccessedTime, + String attributeName, Object attributeValue) { + session.setLastAccessedTime(lastAccessedTime); + session.setAttribute(attributeName, attributeValue); + } + + @Configuration + @EnableSpringHttpSession + static class Config extends BaseConfig { + + @Bean + public SimpleRedisOperationsSessionRepository sessionRepository( + RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.afterPropertiesSet(); + return new SimpleRedisOperationsSessionRepository(redisTemplate); + } + + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepository.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepository.java new file mode 100644 index 000000000..3eb0829a4 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepository.java @@ -0,0 +1,297 @@ +/* + * Copyright 2014-2019 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.data.redis; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.session.MapSession; +import org.springframework.session.Session; +import org.springframework.session.SessionRepository; +import org.springframework.util.Assert; + +/** + * A {@link SessionRepository} implementation that uses Spring Data's + * {@link RedisOperations} to store sessions is Redis. + *

+ * This implementation does not support publishing of session events. + * + * @author Vedran Pavic + * @since 2.2.0 + */ +public class SimpleRedisOperationsSessionRepository implements + SessionRepository { + + private static final String DEFAULT_KEY_NAMESPACE = "spring:session:"; + + private final RedisOperations sessionRedisOperations; + + private Duration defaultMaxInactiveInterval = Duration + .ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); + + private String keyNamespace = DEFAULT_KEY_NAMESPACE; + + private RedisFlushMode flushMode = RedisFlushMode.ON_SAVE; + + /** + * Create a new {@link SimpleRedisOperationsSessionRepository} instance. + * @param sessionRedisOperations the {@link RedisOperations} to use for managing + * sessions + */ + public SimpleRedisOperationsSessionRepository( + RedisOperations sessionRedisOperations) { + Assert.notNull(sessionRedisOperations, "sessionRedisOperations mut not be null"); + this.sessionRedisOperations = sessionRedisOperations; + } + + /** + * Set the default maxInactiveInterval. + * @param defaultMaxInactiveInterval the default maxInactiveInterval + */ + public void setDefaultMaxInactiveInterval(Duration defaultMaxInactiveInterval) { + Assert.notNull(defaultMaxInactiveInterval, + "defaultMaxInactiveInterval must not be null"); + this.defaultMaxInactiveInterval = defaultMaxInactiveInterval; + } + + /** + * Set the key namespace. + * @param keyNamespace the key namespace + */ + public void setKeyNamespace(String keyNamespace) { + Assert.hasText(keyNamespace, "keyNamespace must not be empty"); + this.keyNamespace = keyNamespace; + } + + /** + * Set the flush mode. + * @param flushMode the flush mode + */ + public void setFlushMode(RedisFlushMode flushMode) { + Assert.notNull(flushMode, "flushMode must not be null"); + this.flushMode = flushMode; + } + + @Override + public RedisSession createSession() { + RedisSession session = new RedisSession(this.defaultMaxInactiveInterval); + session.flushIfRequired(); + return session; + } + + @Override + public void save(RedisSession session) { + if (!session.isNew) { + String key = getSessionKey( + session.hasChangedSessionId() ? session.originalSessionId + : session.getId()); + Boolean sessionExists = this.sessionRedisOperations.hasKey(key); + if (sessionExists == null || !sessionExists) { + throw new IllegalStateException("Session was invalidated"); + } + } + session.save(); + } + + @Override + public RedisSession findById(String sessionId) { + String key = getSessionKey(sessionId); + Map entries = this.sessionRedisOperations + .opsForHash().entries(key); + if (entries.isEmpty()) { + return null; + } + MapSession session = new RedisSessionMapper(sessionId).apply(entries); + if (session.isExpired()) { + deleteById(sessionId); + return null; + } + return new RedisSession(session); + } + + @Override + public void deleteById(String sessionId) { + String key = getSessionKey(sessionId); + this.sessionRedisOperations.delete(key); + } + + /** + * Returns the {@link RedisOperations} used for sessions. + * @return the {@link RedisOperations} used for sessions + */ + public RedisOperations getSessionRedisOperations() { + return this.sessionRedisOperations; + } + + private String getSessionKey(String sessionId) { + return this.keyNamespace + "sessions:" + sessionId; + } + + /** + * An internal {@link Session} implementation used by this {@link SessionRepository}. + */ + final class RedisSession implements Session { + + private final MapSession cached; + + private final Map delta = new HashMap<>(); + + private boolean isNew; + + private String originalSessionId; + + RedisSession(Duration maxInactiveInterval) { + this(new MapSession()); + this.cached.setMaxInactiveInterval(maxInactiveInterval); + this.delta.put(RedisSessionMapper.CREATION_TIME_KEY, + getCreationTime().toEpochMilli()); + this.delta.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + (int) getMaxInactiveInterval().getSeconds()); + this.delta.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, + getLastAccessedTime().toEpochMilli()); + this.isNew = true; + } + + RedisSession(MapSession cached) { + this.cached = cached; + this.originalSessionId = cached.getId(); + } + + @Override + public String getId() { + return this.cached.getId(); + } + + @Override + public String changeSessionId() { + return this.cached.changeSessionId(); + } + + @Override + public T getAttribute(String attributeName) { + return this.cached.getAttribute(attributeName); + } + + @Override + public Set getAttributeNames() { + return this.cached.getAttributeNames(); + } + + @Override + public void setAttribute(String attributeName, Object attributeValue) { + this.cached.setAttribute(attributeName, attributeValue); + putAttribute(RedisSessionMapper.ATTRIBUTE_PREFIX + attributeName, + attributeValue); + } + + @Override + public void removeAttribute(String attributeName) { + setAttribute(attributeName, null); + } + + @Override + public Instant getCreationTime() { + return this.cached.getCreationTime(); + } + + @Override + public void setLastAccessedTime(Instant lastAccessedTime) { + this.cached.setLastAccessedTime(lastAccessedTime); + putAttribute(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, + getLastAccessedTime().toEpochMilli()); + } + + @Override + public Instant getLastAccessedTime() { + return this.cached.getLastAccessedTime(); + } + + @Override + public void setMaxInactiveInterval(Duration interval) { + this.cached.setMaxInactiveInterval(interval); + putAttribute(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + (int) getMaxInactiveInterval().getSeconds()); + } + + @Override + public Duration getMaxInactiveInterval() { + return this.cached.getMaxInactiveInterval(); + } + + @Override + public boolean isExpired() { + return this.cached.isExpired(); + } + + private void flushIfRequired() { + if (SimpleRedisOperationsSessionRepository.this.flushMode == RedisFlushMode.IMMEDIATE) { + save(); + } + } + + private boolean hasChangedSessionId() { + return !getId().equals(this.originalSessionId); + } + + private void save() { + saveChangeSessionId(); + saveDelta(); + if (this.isNew) { + this.isNew = false; + } + } + + private void saveChangeSessionId() { + if (hasChangedSessionId()) { + if (!this.isNew) { + String originalSessionIdKey = getSessionKey(this.originalSessionId); + String sessionIdKey = getSessionKey(getId()); + SimpleRedisOperationsSessionRepository.this.sessionRedisOperations + .rename(originalSessionIdKey, sessionIdKey); + } + this.originalSessionId = getId(); + } + } + + private void saveDelta() { + if (this.delta.isEmpty()) { + return; + } + String key = getSessionKey(getId()); + SimpleRedisOperationsSessionRepository.this.sessionRedisOperations + .opsForHash().putAll(key, new HashMap<>(this.delta)); + SimpleRedisOperationsSessionRepository.this.sessionRedisOperations + .expireAt(key, + Date.from(Instant + .ofEpochMilli(getLastAccessedTime().toEpochMilli()) + .plusSeconds(getMaxInactiveInterval().getSeconds()))); + this.delta.clear(); + } + + private void putAttribute(String name, Object value) { + this.delta.put(name, value); + flushIfRequired(); + } + + } + +} diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryTests.java new file mode 100644 index 000000000..52e7b2b94 --- /dev/null +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryTests.java @@ -0,0 +1,343 @@ +/* + * Copyright 2014-2019 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.data.redis; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.session.MapSession; +import org.springframework.session.data.redis.SimpleRedisOperationsSessionRepository.RedisSession; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * Tests for {@link SimpleRedisOperationsSessionRepository}. + * + * @author Vedran Pavic + */ +class SimpleRedisOperationsSessionRepositoryTests { + + private static final String TEST_SESSION_ID = "session-id"; + + private static final String TEST_SESSION_KEY = getSessionKey(TEST_SESSION_ID); + + @Mock + private RedisOperations sessionRedisOperations; + + @Mock + private HashOperations sessionHashOperations; + + @Captor + private ArgumentCaptor> delta; + + private SimpleRedisOperationsSessionRepository sessionRepository; + + @BeforeEach + void setUp() { + MockitoAnnotations.initMocks(this); + given(this.sessionRedisOperations.opsForHash()) + .willReturn(this.sessionHashOperations); + this.sessionRepository = new SimpleRedisOperationsSessionRepository( + this.sessionRedisOperations); + } + + @Test + void constructor_NullRedisOperations_ShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new ReactiveRedisOperationsSessionRepository(null)) + .withMessageContaining("sessionRedisOperations cannot be null"); + } + + @Test + void setDefaultMaxInactiveInterval_ValidInterval_ShouldSetInterval() { + this.sessionRepository.setDefaultMaxInactiveInterval(Duration.ofMinutes(10)); + assertThat(ReflectionTestUtils.getField(this.sessionRepository, + "defaultMaxInactiveInterval")).isEqualTo(Duration.ofMinutes(10)); + } + + @Test + void setDefaultMaxInactiveInterval_NullInterval_ShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy( + () -> this.sessionRepository.setDefaultMaxInactiveInterval(null)) + .withMessage("defaultMaxInactiveInterval must not be null"); + } + + @Test + void setKeyNamespace_ValidNamespace_ShouldSetNamespace() { + this.sessionRepository.setKeyNamespace("test:"); + assertThat(ReflectionTestUtils.getField(this.sessionRepository, "keyNamespace")) + .isEqualTo("test:"); + } + + @Test + void setKeyNamespace_NullNamespace_ShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.sessionRepository.setKeyNamespace(null)) + .withMessage("keyNamespace must not be empty"); + } + + @Test + void setKeyNamespace_EmptyNamespace_ShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.sessionRepository.setKeyNamespace(" ")) + .withMessage("keyNamespace must not be empty"); + } + + @Test + void setFlushMode_ValidFlushMode_ShouldSetFlushMode() { + this.sessionRepository.setFlushMode(RedisFlushMode.IMMEDIATE); + assertThat(ReflectionTestUtils.getField(this.sessionRepository, "flushMode")) + .isEqualTo(RedisFlushMode.IMMEDIATE); + } + + @Test + void setFlushMode_NullFlushMode_ShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.sessionRepository.setFlushMode(null)) + .withMessage("flushMode must not be null"); + } + + @Test + void createSession_DefaultMaxInactiveInterval_ShouldCreateSession() { + RedisSession redisSession = this.sessionRepository.createSession(); + assertThat(redisSession.getMaxInactiveInterval()).isEqualTo( + Duration.ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void createSession_CustomMaxInactiveInterval_ShouldCreateSession() { + this.sessionRepository.setDefaultMaxInactiveInterval(Duration.ofMinutes(10)); + RedisSession redisSession = this.sessionRepository.createSession(); + assertThat(redisSession.getMaxInactiveInterval()) + .isEqualTo(Duration.ofMinutes(10)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void createSession_ImmediateFlushMode_ShouldCreateSession() { + this.sessionRepository.setFlushMode(RedisFlushMode.IMMEDIATE); + RedisSession session = this.sessionRepository.createSession(); + String key = getSessionKey(session.getId()); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionRedisOperations).expireAt(eq(key), eq(getExpiry(session))); + verify(this.sessionHashOperations).putAll(eq(key), this.delta.capture()); + assertThat(this.delta.getValue()).hasSize(3); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void save_NewSession_ShouldSaveSession() { + RedisSession session = this.sessionRepository.createSession(); + this.sessionRepository.save(session); + String key = getSessionKey(session.getId()); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionRedisOperations).expireAt(eq(key), eq(getExpiry(session))); + verify(this.sessionHashOperations).putAll(eq(key), this.delta.capture()); + assertThat(this.delta.getValue()).hasSize(3); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void save_NewSessionAndCustomKeyNamespace_ShouldSaveSession() { + this.sessionRepository.setKeyNamespace("custom:"); + RedisSession session = this.sessionRepository.createSession(); + this.sessionRepository.save(session); + String key = "custom:sessions:" + session.getId(); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionRedisOperations).expireAt(eq(key), eq(getExpiry(session))); + verify(this.sessionHashOperations).putAll(eq(key), this.delta.capture()); + assertThat(this.delta.getValue()).hasSize(3); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void save_NewSessionAndChangedSessionId_ShouldSaveSession() { + RedisSession session = this.sessionRepository.createSession(); + session.changeSessionId(); + this.sessionRepository.save(session); + String key = getSessionKey(session.getId()); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionRedisOperations).expireAt(eq(key), eq(getExpiry(session))); + verify(this.sessionHashOperations).putAll(eq(key), this.delta.capture()); + assertThat(this.delta.getValue()).hasSize(3); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void save_SessionExistsAndHasChanges_ShouldSaveSession() { + given(this.sessionRedisOperations.hasKey(eq(TEST_SESSION_KEY))).willReturn(true); + RedisSession session = createTestSession(); + session.setLastAccessedTime(Instant.now()); + session.setAttribute("attribute2", "value2"); + this.sessionRepository.save(session); + verify(this.sessionRedisOperations).hasKey(eq(TEST_SESSION_KEY)); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionRedisOperations).expireAt(eq(TEST_SESSION_KEY), + eq(getExpiry(session))); + verify(this.sessionHashOperations).putAll(eq(TEST_SESSION_KEY), + this.delta.capture()); + assertThat(this.delta.getValue()).hasSize(2); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void save_SessionExistsAndNoChanges_ShouldSaveSession() { + given(this.sessionRedisOperations.hasKey(eq(TEST_SESSION_KEY))).willReturn(true); + RedisSession session = createTestSession(); + this.sessionRepository.save(session); + verify(this.sessionRedisOperations).hasKey(eq(TEST_SESSION_KEY)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void save_SessionNotExists_ShouldThrowException() { + RedisSession session = createTestSession(); + assertThatIllegalStateException() + .isThrownBy(() -> this.sessionRepository.save(session)) + .withMessage("Session was invalidated"); + verify(this.sessionRedisOperations).hasKey(eq(TEST_SESSION_KEY)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + @SuppressWarnings("unchecked") + void findById_SessionExists_ShouldReturnSession() { + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + given(this.sessionHashOperations.entries(eq(TEST_SESSION_KEY))).willReturn( + mapOf(RedisSessionMapper.CREATION_TIME_KEY, Instant.EPOCH.toEpochMilli(), + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, now.toEpochMilli(), + RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS, + RedisSessionMapper.ATTRIBUTE_PREFIX + "attribute1", "value1")); + RedisSession session = this.sessionRepository.findById(TEST_SESSION_ID); + assertThat(session.getId()).isEqualTo(TEST_SESSION_ID); + assertThat(session.getCreationTime()).isEqualTo(Instant.EPOCH); + assertThat(session.getLastAccessedTime()).isEqualTo(now); + assertThat(session.getMaxInactiveInterval()).isEqualTo( + Duration.ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS)); + assertThat(session.getAttributeNames()) + .isEqualTo(Collections.singleton("attribute1")); + assertThat(session.getAttribute("attribute1")).isEqualTo("value1"); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionHashOperations).entries(eq(TEST_SESSION_KEY)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + @SuppressWarnings("unchecked") + void findById_SessionExistsAndIsExpired_ShouldReturnNull() { + given(this.sessionHashOperations.entries(eq(TEST_SESSION_KEY))).willReturn(mapOf( + RedisSessionMapper.CREATION_TIME_KEY, Instant.EPOCH.toEpochMilli(), + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, Instant.EPOCH.toEpochMilli(), + RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS, + RedisSessionMapper.ATTRIBUTE_PREFIX + "attribute1", "value1")); + assertThat(this.sessionRepository.findById(TEST_SESSION_ID)).isNull(); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionHashOperations).entries(eq(TEST_SESSION_KEY)); + verify(this.sessionRedisOperations).delete(eq(TEST_SESSION_KEY)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void findById_SessionNotExists_ShouldReturnNull() { + assertThat(this.sessionRepository.findById(TEST_SESSION_ID)).isNull(); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionHashOperations).entries(eq(TEST_SESSION_KEY)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void deleteById__ShouldDeleteSession() { + this.sessionRepository.deleteById(TEST_SESSION_ID); + verify(this.sessionRedisOperations).delete(TEST_SESSION_KEY); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void getSessionRedisOperations__ShouldReturnRedisOperations() { + assertThat(this.sessionRepository.getSessionRedisOperations()) + .isEqualTo(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + private static String getSessionKey(String sessionId) { + return "spring:session:sessions:" + sessionId; + } + + private static Date getExpiry(RedisSession session) { + return Date + .from(Instant.ofEpochMilli(session.getLastAccessedTime().toEpochMilli()) + .plusSeconds(session.getMaxInactiveInterval().getSeconds())); + } + + private static Map mapOf(Object... objects) { + Map result = new HashMap<>(); + if (objects != null) { + for (int i = 0; i < objects.length; i += 2) { + result.put((String) objects[i], objects[i + 1]); + } + } + return result; + } + + private RedisSession createTestSession() { + MapSession cached = new MapSession(TEST_SESSION_ID); + cached.setCreationTime(Instant.EPOCH); + cached.setLastAccessedTime(Instant.EPOCH); + cached.setAttribute("attribute1", "value1"); + return this.sessionRepository.new RedisSession(cached); + } + +} From 791050cdc0893ae6122f44f7c035b4e06fc86426 Mon Sep 17 00:00:00 2001 From: Vedran Pavic Date: Thu, 13 Jun 2019 22:09:55 +0200 Subject: [PATCH 3/3] Add SimpleRedisOperationsSessionRepository sample --- .../src/docs/asciidoc/index.adoc | 4 + ...ng-session-sample-boot-redis-simple.gradle | 21 +++ .../java/sample/BootTests.java | 103 ++++++++++++++ .../java/sample/pages/BasePage.java | 38 ++++++ .../java/sample/pages/HomePage.java | 62 +++++++++ .../java/sample/pages/LoginPage.java | 65 +++++++++ .../resources/testcontainers.properties | 1 + .../src/main/java/sample/Application.java | 29 ++++ .../java/sample/config/SecurityConfig.java | 40 ++++++ .../java/sample/config/SessionConfig.java | 52 +++++++ .../main/java/sample/config/WebMvcConfig.java | 31 +++++ .../src/main/resources/application.properties | 1 + .../src/main/resources/static/favicon.ico | Bin 0 -> 1150 bytes .../src/main/resources/static/images/logo.png | Bin 0 -> 1123 bytes .../src/main/resources/templates/index.html | 11 ++ .../src/main/resources/templates/layout.html | 128 ++++++++++++++++++ 16 files changed, 586 insertions(+) create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/spring-session-sample-boot-redis-simple.gradle create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/BootTests.java create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/pages/BasePage.java create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/pages/HomePage.java create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/pages/LoginPage.java create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/resources/testcontainers.properties create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/Application.java create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/config/SecurityConfig.java create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/config/SessionConfig.java create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/config/WebMvcConfig.java create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/application.properties create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/static/favicon.ico create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/static/images/logo.png create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/templates/index.html create mode 100644 spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/templates/layout.html diff --git a/spring-session-docs/src/docs/asciidoc/index.adoc b/spring-session-docs/src/docs/asciidoc/index.adoc index 59d472fa7..7b2658fa9 100644 --- a/spring-session-docs/src/docs/asciidoc/index.adoc +++ b/spring-session-docs/src/docs/asciidoc/index.adoc @@ -74,6 +74,10 @@ To get started with Spring Session, the best place to start is our Sample Applic | Demonstrates how to use Spring Session to replace the `HttpSession` with Redis using JSON serialization. | +| {gh-samples-url}spring-session-sample-boot-redis-simple[HttpSession with simple Redis `SessionRepository`] +| Demonstrates how to use Spring Session to replace the `HttpSession` with Redis using `SimpleRedisOperationsSessionRepository`. +| + |=== .Sample Applications that use Spring Java-based configuration diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/spring-session-sample-boot-redis-simple.gradle b/spring-session-samples/spring-session-sample-boot-redis-simple/spring-session-sample-boot-redis-simple.gradle new file mode 100644 index 000000000..dafb79e41 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-redis-simple/spring-session-sample-boot-redis-simple.gradle @@ -0,0 +1,21 @@ +apply plugin: 'io.spring.convention.spring-sample-boot' + +dependencies { + compile project(':spring-session-data-redis') + compile 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' + compile 'org.springframework.boot:spring-boot-starter-data-redis' + compile 'org.springframework.boot:spring-boot-starter-security' + compile 'org.springframework.boot:spring-boot-starter-thymeleaf' + compile 'org.springframework.boot:spring-boot-starter-web' + compile 'org.springframework.boot:spring-boot-devtools' + compile 'org.webjars:bootstrap' + compile 'org.webjars:html5shiv' + compile 'org.webjars:webjars-locator-core' + + testCompile 'org.junit.jupiter:junit-jupiter-api' + testRuntime 'org.junit.jupiter:junit-jupiter-engine' + testCompile 'org.springframework.boot:spring-boot-starter-test' + + integrationTestCompile seleniumDependencies + integrationTestCompile 'org.testcontainers:testcontainers' +} diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/BootTests.java b/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/BootTests.java new file mode 100644 index 000000000..b52edcebd --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/BootTests.java @@ -0,0 +1,103 @@ +/* + * Copyright 2014-2019 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 sample; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.openqa.selenium.WebDriver; +import org.testcontainers.containers.GenericContainer; +import sample.pages.HomePage; +import sample.pages.LoginPage; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.htmlunit.webdriver.MockMvcHtmlUnitDriverBuilder; + +@ExtendWith(SpringExtension.class) +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = WebEnvironment.MOCK) +class BootTests { + + private static final String DOCKER_IMAGE = "redis:5.0.5"; + + @Autowired + private MockMvc mockMvc; + + private WebDriver driver; + + @BeforeEach + void setup() { + this.driver = MockMvcHtmlUnitDriverBuilder.mockMvcSetup(this.mockMvc).build(); + } + + @AfterEach + void tearDown() { + this.driver.quit(); + } + + @Test + void home() { + LoginPage login = HomePage.go(this.driver); + login.assertAt(); + } + + @Test + void login() { + LoginPage login = HomePage.go(this.driver); + HomePage home = login.form().login(HomePage.class); + home.assertAt(); + home.containCookie("SESSION"); + home.doesNotContainCookie("JSESSIONID"); + } + + @Test + void logout() { + LoginPage login = HomePage.go(this.driver); + HomePage home = login.form().login(HomePage.class); + home.logout(); + login.assertAt(); + } + + @TestConfiguration + static class Config { + + @Bean + public GenericContainer redisContainer() { + GenericContainer redisContainer = new GenericContainer(DOCKER_IMAGE) + .withExposedPorts(6379); + redisContainer.start(); + return redisContainer; + } + + @Bean + public LettuceConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisContainer().getContainerIpAddress(), + redisContainer().getFirstMappedPort()); + } + + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/pages/BasePage.java b/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/pages/BasePage.java new file mode 100644 index 000000000..9159c0787 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/pages/BasePage.java @@ -0,0 +1,38 @@ +/* + * Copyright 2014-2019 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 sample.pages; + +import org.openqa.selenium.WebDriver; + +public class BasePage { + + private WebDriver driver; + + public BasePage(WebDriver driver) { + this.driver = driver; + } + + public WebDriver getDriver() { + return this.driver; + } + + public static void get(WebDriver driver, String get) { + String baseUrl = "http://localhost"; + driver.get(baseUrl + get); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/pages/HomePage.java b/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/pages/HomePage.java new file mode 100644 index 000000000..2e0c28e0c --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/pages/HomePage.java @@ -0,0 +1,62 @@ +/* + * Copyright 2014-2019 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 sample.pages; + +import java.util.Set; + +import org.openqa.selenium.By; +import org.openqa.selenium.Cookie; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.PageFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HomePage extends BasePage { + + public HomePage(WebDriver driver) { + super(driver); + } + + public static LoginPage go(WebDriver driver) { + get(driver, "/"); + return PageFactory.initElements(driver, LoginPage.class); + } + + public void assertAt() { + assertThat(getDriver().getTitle()) + .isEqualTo("Spring Session Sample - Secured Content"); + } + + public void containCookie(String cookieName) { + Set cookies = getDriver().manage().getCookies(); + assertThat(cookies).extracting("name").contains(cookieName); + } + + public void doesNotContainCookie(String cookieName) { + Set cookies = getDriver().manage().getCookies(); + assertThat(cookies).extracting("name").doesNotContain(cookieName); + } + + public HomePage logout() { + WebElement logout = getDriver() + .findElement(By.cssSelector("input[type=\"submit\"]")); + logout.click(); + return PageFactory.initElements(getDriver(), HomePage.class); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/pages/LoginPage.java b/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/pages/LoginPage.java new file mode 100644 index 000000000..99fd0faec --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/java/sample/pages/LoginPage.java @@ -0,0 +1,65 @@ +/* + * Copyright 2014-2019 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 sample.pages; + +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; +import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +public class LoginPage extends BasePage { + + public LoginPage(WebDriver driver) { + super(driver); + } + + public void assertAt() { + assertThat(getDriver().getTitle()).isEqualTo("Please sign in"); + } + + public Form form() { + return new Form(getDriver()); + } + + public class Form { + + @FindBy(name = "username") + private WebElement username; + + @FindBy(name = "password") + private WebElement password; + + @FindBy(tagName = "button") + private WebElement button; + + public Form(SearchContext context) { + PageFactory.initElements(new DefaultElementLocatorFactory(context), this); + } + + public T login(Class page) { + this.username.sendKeys("user"); + this.password.sendKeys("password"); + this.button.click(); + return PageFactory.initElements(getDriver(), page); + } + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/resources/testcontainers.properties b/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/resources/testcontainers.properties new file mode 100644 index 000000000..e3e834192 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-redis-simple/src/integration-test/resources/testcontainers.properties @@ -0,0 +1 @@ +ryuk.container.timeout=120 diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/Application.java b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/Application.java new file mode 100644 index 000000000..e9c3db94f --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/Application.java @@ -0,0 +1,29 @@ +/* + * Copyright 2014-2019 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 sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/config/SecurityConfig.java b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/config/SecurityConfig.java new file mode 100644 index 000000000..3d6b979e7 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/config/SecurityConfig.java @@ -0,0 +1,40 @@ +/* + * Copyright 2014-2019 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 sample.config; + +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +@Configuration +public class SecurityConfig extends WebSecurityConfigurerAdapter { + + // @formatter:off + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .authorizeRequests() + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .anyRequest().authenticated() + .and() + .formLogin() + .permitAll(); + } + // @formatter:on + +} diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/config/SessionConfig.java b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/config/SessionConfig.java new file mode 100644 index 000000000..c06f9558a --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/config/SessionConfig.java @@ -0,0 +1,52 @@ +/* + * Copyright 2014-2019 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 sample.config; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession; +import org.springframework.session.data.redis.SimpleRedisOperationsSessionRepository; + +@EnableSpringHttpSession +public class SessionConfig { + + private final RedisConnectionFactory redisConnectionFactory; + + public SessionConfig(ObjectProvider redisConnectionFactory) { + this.redisConnectionFactory = redisConnectionFactory.getIfAvailable(); + } + + @Bean + public RedisOperations sessionRedisOperations() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(this.redisConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + return redisTemplate; + } + + @Bean + public SimpleRedisOperationsSessionRepository sessionRepository( + RedisOperations sessionRedisOperations) { + return new SimpleRedisOperationsSessionRepository(sessionRedisOperations); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/config/WebMvcConfig.java b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/config/WebMvcConfig.java new file mode 100644 index 000000000..1c1a29258 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/java/sample/config/WebMvcConfig.java @@ -0,0 +1,31 @@ +/* + * Copyright 2014-2019 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 sample.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Override + public void addViewControllers(ViewControllerRegistry registry) { + registry.addViewController("/").setViewName("index"); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/application.properties b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/application.properties new file mode 100644 index 000000000..1b5271b53 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.security.user.password=password diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/static/favicon.ico b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..bfb9974019d4b8b978cf34ea67f0c6804f9b76c6 GIT binary patch literal 1150 zcmZQzU<5(|0R|wcz>vYhz#zuJz@P!dKp~(AL>x#lFaYI*xFHzK2NLHVUHb1iG2*{Y zgB?lOds5VYkY4wx3IDZQ-O0jkQ{(?@bp=p{C&v8O!h&CXe)}H=o_u_bRSig==8O!i zc=x^YFzx?WUq6CX4M?xX{1U9V`}7vL{^Em6v8n;-RbSqS702#f2-g1(NLsD$!KwzN zS8ZJ%R&20o^8Y{o{{6r8@eNjaxV~+({;Q#3&F!=Q_uf49|L5O7|6hFn@<03RE(|p= zJ*s<_{YS^WcP_&H4q|}(5Orn`hB}ZwmBSk_@QDwvQS^g2lONu|PzTm~V&{JqG(7$3 zJq-P6H_u_H1L;#fa{vQto;~z`$*ZUT-~ayo|MAc7|C=7&#!v&-2UM#}0rtFhmTdiw fS5E!E^7Zq7x2tDJHpBVK>Hp`xeEbh92gs!XBi)`m literal 0 HcmV?d00001 diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/static/images/logo.png b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/static/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..393230883fb4e04829339f7c21b6ab6de7393891 GIT binary patch literal 1123 zcmV-p1f2VcP)P000yS1^@s74{)X{0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU$1xZ9fRCwBAJakr>0R&#%?*!8<7oReN z=>H6CRSb*_9RHalf3;=rzRt=3!VW|AET)fs_IS z5aXsJDqv;5UUXCgspCNG%qCu4_4m8Q8J4{_Ss=DB)G%-}OK$)A_qiv-M@As~;az|L zf?EIsP`7cheE7}?GEN=HaRK6^jLh%PvWj*xv3z@3xac)oRV-9P3=lIhyklZuIsRdC zEQk#P>`Ve}KmUCIvzcu9Tf*+YVP*hfGu{$8D7y_HfEf3kMztIrynEOQW2*Ct&A;*G zO}I7VlHICc5w;GSdsSFH1nBbCHtgWCZ;JNm@ z`XjO?7RGP?S?;`M26}|$>d$}7+Q^c380tVF%ErLJjAqwYZkA^sS%0xHF}~wxlbiD5 z`(qoR=no*41>&DbDvtqiaJE+C3lIk&fG}bK9l%ly6jZSM`Nu#MU}XG#RzRlJh~>*q zMz|m_S~GwYKiDe_40nN83W%$i`8HWIa~)&&`WqMkpMdet@v+_G7x%2~|M)9$Tk#!Y zFw1)&gOROi`~TlS|Ne#uGBU8e{?G6W7-39r7#RMspu`I>^-8__`jg@PS4KGI{tDI)dzZ%dlS>M59FF!%vUTz)~b4X4$^~`TvW7 ziIMs5zyE)kk;ES}`AIdM1t$C=P~wMS3!!9Ap!k0ap%i9haWBalIY!4O?f(oAd%r+4 zo)k2;d7%b_#Jhnp-2~zQ1Q@}XCIrw@0t|34GX2=V#dp;1>j!5(mhZn98K?y0fw-87 z<#h&!;7V3zwkKdg)?fU+ET4a(DSgvn03;_eFu%5AV7&(n_piW02N=Hpff?mLFf{&g z0%=ZYR2CvOD9l@{`Ma!o3LDu1eZb}An>(a<~Q?=8{fF=xc@W!MoZ)b z{O|{gu_$Ev|5TXe{Z}Tay7jj||2@OO@D(hD)hsRsjrwm4H=1C4P6p+J-x==O;Vpp} zelf7T{l>^3$L4GI;MZmjLQek2%+jdD-YkChTV1vRXZ6WjKj)c!`-fi-2P5B87D!gv za)A+O=YLR%j|aSGxW@31vGu^;|BMVrzfaux>)%&1LJk9(o(#_DK=28Oy@5ho@oC^@ z5m@o{&xh3zgTK{rGV>dM|NF@cm+`=a;1wp + + Secured Content + + +

+

Secured Page

+

This page is secured using Spring Boot, Spring Session, and Spring Security.

+
+ + diff --git a/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/templates/layout.html b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/templates/layout.html new file mode 100644 index 000000000..6c4dbdc64 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-redis-simple/src/main/resources/templates/layout.html @@ -0,0 +1,128 @@ + + + + Spring Session Sample + + + + + + + + + + + +
+ + +
+
+ Some Success message +
+
+ Fake content +
+
+ +
+
+ + + +