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(); + } +}