diff --git a/pom.xml b/pom.xml
index cf08bb08b2..6ef6abd805 100644
--- a/pom.xml
+++ b/pom.xml
@@ -999,7 +999,7 @@
spring-5-webflux-2
spring-activiti
spring-batch-2
- spring-boot-modules/spring-caching-2
+ spring-boot-modules/spring-caching-2
spring-core-2
spring-core-3
spring-core-5
@@ -1022,6 +1022,7 @@
webrtc
persistence-modules/java-mongodb
messaging-modules/spring-apache-camel
+ spring-boot-modules/spring-boot-redis
@@ -1258,7 +1259,7 @@
spring-5-webflux-2
spring-activiti
spring-batch-2
- spring-boot-modules/spring-caching-2
+ spring-boot-modules/spring-caching-2
spring-core-2
spring-core-3
spring-core-5
@@ -1281,6 +1282,7 @@
persistence-modules/java-mongodb
libraries-2
messaging-modules/spring-apache-camel
+ spring-boot-modules/spring-boot-redis
diff --git a/spring-boot-modules/pom.xml b/spring-boot-modules/pom.xml
index 7fdfac4b93..969ab8bffa 100644
--- a/spring-boot-modules/pom.xml
+++ b/spring-boot-modules/pom.xml
@@ -106,4 +106,4 @@
-
\ No newline at end of file
+
diff --git a/spring-boot-modules/spring-boot-redis/.gitignore b/spring-boot-modules/spring-boot-redis/.gitignore
new file mode 100644
index 0000000000..a8e6c9dbce
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/.gitignore
@@ -0,0 +1,35 @@
+HELP.md
+target/
+.mvn
+mvnw
+mvnw.cmd
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/spring-boot-modules/spring-boot-redis/pom.xml b/spring-boot-modules/spring-boot-redis/pom.xml
new file mode 100644
index 0000000000..42aa1321d5
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/pom.xml
@@ -0,0 +1,69 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.0.2
+
+
+ com.baelding
+ spring-boot-redis
+ 0.0.1-SNAPSHOT
+ spring-boot-redis
+ Demo project for Spring Boot with Spring Data Redis
+
+ 15
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ it.ozimov
+ embedded-redis
+ 0.7.3
+ test
+
+
+ org.springframework
+ spring-webflux
+ 6.0.3
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
diff --git a/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/SpringBootRedisApplication.java b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/SpringBootRedisApplication.java
new file mode 100644
index 0000000000..43d56e29ea
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/SpringBootRedisApplication.java
@@ -0,0 +1,13 @@
+package com.baelding.springbootredis;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SpringBootRedisApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(SpringBootRedisApplication.class, args);
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/config/RedisConfiguration.java b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/config/RedisConfiguration.java
new file mode 100644
index 0000000000..2c050af8af
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/config/RedisConfiguration.java
@@ -0,0 +1,58 @@
+package com.baelding.springbootredis.config;
+
+import com.baelding.springbootredis.model.Session;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationListener;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.event.EventListener;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisKeyExpiredEvent;
+import org.springframework.data.redis.core.RedisKeyValueAdapter;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.convert.KeyspaceConfiguration;
+import org.springframework.data.redis.core.convert.MappingConfiguration;
+import org.springframework.data.redis.core.index.IndexConfiguration;
+import org.springframework.data.redis.core.mapping.RedisMappingContext;
+import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+
+@Configuration
+@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
+@Slf4j
+public class RedisConfiguration {
+ @Bean
+ public RedisTemplate getRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
+ RedisTemplate redisTemplate = new RedisTemplate<>();
+ redisTemplate.setConnectionFactory(redisConnectionFactory);
+
+ return redisTemplate;
+ }
+
+ @Bean
+ public RedisMappingContext keyValueMappingContext() {
+ return new RedisMappingContext(new MappingConfiguration(new IndexConfiguration(), new MyKeyspaceConfiguration()));
+ }
+
+ public static class MyKeyspaceConfiguration extends KeyspaceConfiguration {
+
+ @Override
+ protected Iterable initialConfiguration() {
+ KeyspaceSettings keyspaceSettings = new KeyspaceSettings(Session.class, "session");
+ keyspaceSettings.setTimeToLive(60L);
+ return Collections.singleton(keyspaceSettings);
+ }
+ }
+
+ @Component
+ public static class SessionExpiredEventListener {
+ @EventListener
+ public void handleRedisKeyExpiredEvent(RedisKeyExpiredEvent event) {
+ Session expiredSession = (Session) event.getValue();
+ assert expiredSession != null;
+ log.info("Session with key={} has expired", expiredSession.getId());
+ }
+ }
+}
diff --git a/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/controller/SessionController.java b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/controller/SessionController.java
new file mode 100644
index 0000000000..156ca0195f
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/controller/SessionController.java
@@ -0,0 +1,49 @@
+package com.baelding.springbootredis.controller;
+
+import com.baelding.springbootredis.dto.SessionCreateRequest;
+import com.baelding.springbootredis.model.Session;
+import com.baelding.springbootredis.service.cache.session.SessionCache;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/v1/sessions")
+public class SessionController {
+ private static final String LOCATION_HEADER_VALUE_FORMAT = "/v1/sessions/%s";
+
+ @Autowired
+ private SessionCache sessionCache;
+
+ @GetMapping
+ public List getAllSessions() {
+ return sessionCache.getAllSessions();
+ }
+
+ @GetMapping("/{id}")
+ public Session getSession(@PathVariable("id") String id) {
+ return sessionCache.getSession(id);
+ }
+
+ @PostMapping
+ public ResponseEntity createASession(@RequestBody SessionCreateRequest sessionCreateRequest) {
+ Session createdSession = sessionCache.createASession(Session.builder().expirationInSeconds(sessionCreateRequest.getExpirationInSeconds()).build());
+
+ MultiValueMap headers = new LinkedMultiValueMap<>();
+ headers.set(HttpHeaders.LOCATION, String.format(LOCATION_HEADER_VALUE_FORMAT, createdSession.getId()));
+
+ return new ResponseEntity<>(createdSession, new HttpHeaders(headers), HttpStatus.CREATED);
+ }
+}
diff --git a/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/dto/SessionCreateRequest.java b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/dto/SessionCreateRequest.java
new file mode 100644
index 0000000000..18fed5c034
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/dto/SessionCreateRequest.java
@@ -0,0 +1,14 @@
+package com.baelding.springbootredis.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class SessionCreateRequest {
+ private Long expirationInSeconds;
+}
diff --git a/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/exception/session/SessionNotFoundException.java b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/exception/session/SessionNotFoundException.java
new file mode 100644
index 0000000000..ff4e0e2947
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/exception/session/SessionNotFoundException.java
@@ -0,0 +1,12 @@
+package com.baelding.springbootredis.exception.session;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+@ResponseStatus(value = HttpStatus.NOT_FOUND)
+public class SessionNotFoundException extends RuntimeException {
+ private static final String MESSAGE_FORMAT = "Session with id=%s doesn't exists!";
+ public SessionNotFoundException(String id) {
+ super(String.format(MESSAGE_FORMAT, id));
+ }
+}
diff --git a/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/model/Session.java b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/model/Session.java
new file mode 100644
index 0000000000..5c9d26147e
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/model/Session.java
@@ -0,0 +1,19 @@
+package com.baelding.springbootredis.model;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.redis.core.RedisHash;
+import org.springframework.data.redis.core.TimeToLive;
+
+@Data
+@Builder
+@EqualsAndHashCode
+@RedisHash(timeToLive = 60L)
+public class Session {
+ @Id
+ private String id;
+ @TimeToLive
+ private Long expirationInSeconds;
+}
diff --git a/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/repository/SessionRepository.java b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/repository/SessionRepository.java
new file mode 100644
index 0000000000..0fbc13aa63
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/repository/SessionRepository.java
@@ -0,0 +1,7 @@
+package com.baelding.springbootredis.repository;
+
+import com.baelding.springbootredis.model.Session;
+import org.springframework.data.repository.CrudRepository;
+
+public interface SessionRepository extends CrudRepository {
+}
diff --git a/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/service/cache/session/RedisSessionCache.java b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/service/cache/session/RedisSessionCache.java
new file mode 100644
index 0000000000..bb89afaeae
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/service/cache/session/RedisSessionCache.java
@@ -0,0 +1,34 @@
+package com.baelding.springbootredis.service.cache.session;
+
+import com.baelding.springbootredis.exception.session.SessionNotFoundException;
+import com.baelding.springbootredis.model.Session;
+import com.baelding.springbootredis.repository.SessionRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+@Service
+public class RedisSessionCache implements SessionCache {
+ @Autowired
+ private SessionRepository sessionRepository;
+
+ @Override
+ public Session createASession(Session session) {
+ return sessionRepository.save(session);
+ }
+
+ @Override
+ public Session getSession(String id) {
+ return sessionRepository.findById(id).orElseThrow(() -> new SessionNotFoundException(id));
+ }
+
+ @Override
+ public List getAllSessions() {
+ return Stream.iterate(sessionRepository.findAll().iterator(), Iterator::hasNext, UnaryOperator.identity()).map(Iterator::next).collect(Collectors.toList());
+ }
+}
diff --git a/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/service/cache/session/SessionCache.java b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/service/cache/session/SessionCache.java
new file mode 100644
index 0000000000..18fb738631
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/src/main/java/com/baelding/springbootredis/service/cache/session/SessionCache.java
@@ -0,0 +1,12 @@
+package com.baelding.springbootredis.service.cache.session;
+
+import com.baelding.springbootredis.model.Session;
+
+import java.util.List;
+
+public interface SessionCache {
+ Session createASession(Session session);
+ Session getSession(String id);
+ List getAllSessions();
+
+}
diff --git a/spring-boot-modules/spring-boot-redis/src/main/resources/application.properties b/spring-boot-modules/spring-boot-redis/src/main/resources/application.properties
new file mode 100644
index 0000000000..8b13789179
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/src/main/resources/application.properties
@@ -0,0 +1 @@
+
diff --git a/spring-boot-modules/spring-boot-redis/src/test/java/com/baelding/springbootredis/SpringBootRedisApplicationIntegrationTest.java b/spring-boot-modules/spring-boot-redis/src/test/java/com/baelding/springbootredis/SpringBootRedisApplicationIntegrationTest.java
new file mode 100644
index 0000000000..69c88706e4
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/src/test/java/com/baelding/springbootredis/SpringBootRedisApplicationIntegrationTest.java
@@ -0,0 +1,146 @@
+package com.baelding.springbootredis;
+
+import com.baelding.springbootredis.config.RedisTestConfiguration;
+import com.baelding.springbootredis.dto.SessionCreateRequest;
+import com.baelding.springbootredis.model.Session;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+import java.util.List;
+import java.util.Random;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = RedisTestConfiguration.class)
+class SpringBootRedisApplicationIntegrationTest {
+
+ private static final String V1_SESSIONS_ENDPOINT = "/v1/sessions";
+ private static final String V1_GET_SESSION_BY_ID_ENDPOINT_TEMPLATE = V1_SESSIONS_ENDPOINT + "/%s";
+ @Autowired
+ private WebTestClient webTestClient;
+ private static Random random;
+
+ @BeforeAll
+ public static void beforeAll() {
+ random = new Random();
+ }
+
+ @Test
+ @DisplayName("""
+ WHEN get session endpoint is called
+ THEN return a success response.
+ """)
+ void shouldBeAbleToCallGetSessionsEndpoint() {
+ webTestClient.get().uri(V1_SESSIONS_ENDPOINT).exchange().expectStatus().isOk();
+ }
+
+ @Test
+ @DisplayName("""
+ WHEN requested to create a session with certain expiration value
+ THEN successfully create a session with the desired expiration.
+ """)
+ void shouldBeAbleToCreateASessionWithCertainExpiration() {
+ Long expirationInSeconds = 10L;
+
+ SessionCreateRequest sessionCreateRequest = SessionCreateRequest.builder().expirationInSeconds(expirationInSeconds).build();
+
+ Session session = webTestClient.post().uri(V1_SESSIONS_ENDPOINT).bodyValue(sessionCreateRequest).exchange().expectStatus().isCreated().expectHeader().exists(HttpHeaders.LOCATION).expectBody(Session.class).returnResult().getResponseBody();
+
+ Assertions.assertNotNull(session);
+ Assertions.assertEquals(expirationInSeconds, session.getExpirationInSeconds());
+ }
+
+ @Test
+ @DisplayName("""
+ GIVEN one or multiple session exists
+ WHEN get sessions endpoint is called
+ THEN return the all the existing session.
+ """)
+ void shouldBeAbleToGetTheExistingSessions() {
+ // Given
+ SessionCreateRequest session = SessionCreateRequest.builder().expirationInSeconds(300L).build();
+
+ int numberOfSessionsToCreate = random.nextInt(5);
+
+ List createdSessions = IntStream.range(0, numberOfSessionsToCreate)
+ .mapToObj(i -> webTestClient.post().uri(V1_SESSIONS_ENDPOINT).bodyValue(session).exchange().expectStatus().isCreated().expectBody(Session.class).returnResult().getResponseBody())
+ .collect(Collectors.toList());
+
+ // WHEN
+ webTestClient.get().uri(V1_SESSIONS_ENDPOINT).exchange().expectStatus()
+ // THEN
+ .isOk().expectBodyList(Session.class).value(sessions -> createdSessions.forEach(createdSession -> Assertions.assertTrue(sessions.contains(createdSession))));
+ }
+
+ @Test
+ @DisplayName("""
+ GIVEN a session is created with a given identifier
+ WHEN the session with the identifier is requested
+ THEN return the session
+ """)
+ void getSessionById() {
+ // GIVEN
+ SessionCreateRequest sessionCreateRequest = SessionCreateRequest.builder().expirationInSeconds(10L).build();
+
+ Session createdSession = webTestClient.post().uri(V1_SESSIONS_ENDPOINT).bodyValue(sessionCreateRequest).exchange().expectStatus().isCreated().expectBody(Session.class).returnResult().getResponseBody();
+ Assertions.assertNotNull(createdSession);
+
+ // WHEN
+ webTestClient.get().uri(String.format(V1_GET_SESSION_BY_ID_ENDPOINT_TEMPLATE, createdSession.getId())).exchange()
+ // THEN
+ .expectStatus().isOk().expectBody(Session.class).isEqualTo(createdSession);
+ }
+
+ @Test
+ @DisplayName("""
+ GIVEN a session identifier which doesn't exists
+ WHEN the session with the identifier is requested
+ THEN return 404 not found
+ """)
+ void getSessionByIdNotFound() {
+ // GIVEN
+ String sessionId = UUID.randomUUID().toString();
+
+ // WHEN
+ webTestClient.get().uri(String.format(V1_GET_SESSION_BY_ID_ENDPOINT_TEMPLATE, sessionId)).exchange()
+ // THEN
+ .expectStatus().isNotFound();
+ }
+
+ @Test
+ @DisplayName("""
+ GIVEN a session is created with a stipulated expiration
+ WHEN the session is requested before the expiration period
+ THEN return the session
+
+ WHEN the same session is requested after the expiration period
+ THEN return 404 not found
+ """)
+ void sessionExpiration() throws InterruptedException {
+ long expirationInSeconds = 3L;
+
+ // GIVEN
+ SessionCreateRequest sessionCreateRequest = SessionCreateRequest.builder().expirationInSeconds(expirationInSeconds).build();
+
+ Session createdSession = webTestClient.post().uri(V1_SESSIONS_ENDPOINT).bodyValue(sessionCreateRequest).exchange().expectStatus().isCreated().expectBody(Session.class).returnResult().getResponseBody();
+ Assertions.assertNotNull(createdSession);
+
+ // WHEN
+ webTestClient.get().uri(String.format(V1_GET_SESSION_BY_ID_ENDPOINT_TEMPLATE, createdSession.getId())).exchange()
+ // THEN
+ .expectStatus().isOk().expectBody(Session.class).isEqualTo(createdSession);
+
+ // WHEN
+ Thread.sleep(expirationInSeconds * 1000);
+ webTestClient.get().uri(String.format(V1_GET_SESSION_BY_ID_ENDPOINT_TEMPLATE, createdSession.getId())).exchange()
+ // THEN
+ .expectStatus().isNotFound();
+ }
+}
diff --git a/spring-boot-modules/spring-boot-redis/src/test/java/com/baelding/springbootredis/config/RedisTestConfiguration.java b/spring-boot-modules/spring-boot-redis/src/test/java/com/baelding/springbootredis/config/RedisTestConfiguration.java
new file mode 100644
index 0000000000..6fbd140981
--- /dev/null
+++ b/spring-boot-modules/spring-boot-redis/src/test/java/com/baelding/springbootredis/config/RedisTestConfiguration.java
@@ -0,0 +1,27 @@
+package com.baelding.springbootredis.config;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
+import org.springframework.boot.test.context.TestConfiguration;
+import redis.embedded.RedisServer;
+
+@TestConfiguration
+public class RedisTestConfiguration {
+
+ private final RedisServer redisServer;
+
+ public RedisTestConfiguration(RedisProperties redisProperties) {
+ this.redisServer = new RedisServer(redisProperties.getPort());
+ }
+
+ @PostConstruct
+ public void postConstruct() {
+ redisServer.start();
+ }
+
+ @PreDestroy
+ public void preDestroy() {
+ redisServer.stop();
+ }
+}