Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions devslab-kit-cache-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,37 @@ dependencies {
api("org.springframework:spring-context")
api("org.springframework.boot:spring-boot-autoconfigure")

// Redis is OPTIONAL. The Redis-backed CacheManager (PR 2) only wires up when
// the consumer puts these on the classpath themselves (type=redis). Keeping
// them compileOnly here means the kit never forces Redis as a transitive
// Redis is OPTIONAL. The Redis-backed CacheManager only wires up when the
// consumer puts these on the classpath themselves (type=redis). Keeping them
// compileOnly here means the kit never forces Redis as a transitive
// dependency — the in-memory default stays zero-dependency. See ADR 0002 §4.
// (spring-boot-starter-data-redis brings Spring Data Redis + Jackson 3
// tools.jackson transitively, which GenericJacksonJsonRedisSerializer needs.)
compileOnly("org.springframework.boot:spring-boot-starter-data-redis")
compileOnly("com.fasterxml.jackson.core:jackson-databind")
// Our RedisCacheConfiguration references Jackson 3 (JsonMapper) directly to
// build the value serializer. Spring Data Redis declares Jackson `optional`,
// so it isn't on the compile classpath transitively — declare it explicitly.
// compileOnly (like Redis itself): only needed when the consumer opts into
// type=redis, which already puts Jackson 3 on their runtime classpath via
// spring-boot-starter-data-redis. Version managed by the Spring Boot BOM.
compileOnly("tools.jackson.core:jackson-databind")

compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")

// The Redis path is exercised against a real Redis (Testcontainers): the
// serialization round-trip is the whole risk, so it gets an integration test
// rather than a mock. Redis deps are test-scoped here (compileOnly above for
// main), matching how a consumer who opts into type=redis would add them.
testImplementation("org.springframework.boot:spring-boot-starter-data-redis")
// Jackson 3 is `optional` in Spring Data Redis, so the Redis starter doesn't
// pull it transitively even at test scope. The Redis value serializer needs
// it at runtime (PolymorphicTypeValidator etc.) — declare it explicitly for
// the test runtime, mirroring the compileOnly on main. A real consumer who
// opts into type=redis gets Jackson 3 from spring-boot-starter-jackson, which
// SB4 apps always have.
testImplementation("tools.jackson.core:jackson-databind")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:testcontainers-junit-jupiter")
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* one property to get distributed caching and writes zero serializer config.
*
* <p>This class wires the {@code none} and {@code in-memory} backends. The
* {@code redis} backend lives in {@link RedisCacheConfiguration}, imported here
* {@code redis} backend lives in {@link DevslabKitRedisCacheConfiguration}, imported here
* but inert unless Redis is on the classpath (ADR 0002 §2–§3).
*
* <p>Everything is {@link ConditionalOnMissingBean} on {@link CacheManager}: a
Expand All @@ -26,7 +26,7 @@
@AutoConfiguration
@ConditionalOnProperty(prefix = "devslab.kit.cache", name = "enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(CacheProperties.class)
@org.springframework.context.annotation.Import(RedisCacheConfiguration.class)
@org.springframework.context.annotation.Import(DevslabKitRedisCacheConfiguration.class)
public class CacheAutoConfiguration {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ public class CacheProperties {
/** Whether to cache {@code null} return values. Off by default so a cached null doesn't mask a real miss. */
private boolean cacheNullValues = false;

/**
* Base package allowed for polymorphic deserialization when {@code type=redis}.
* The Redis value serializer enables JSON default typing (so records round-trip
* with their concrete type) but constrains it to an allow-list — {@code java.*}
* plus this package — to avoid the deserialization-gadget risk of unrestricted
* default typing. A consumer caching their own types sets this to their root
* package (e.g. {@code com.acme}); the default covers the kit's own types.
*/
private String allowedPackage = "kr.devslab";

public boolean isEnabled() {
return enabled;
}
Expand Down Expand Up @@ -73,4 +83,12 @@ public boolean isCacheNullValues() {
public void setCacheNullValues(boolean cacheNullValues) {
this.cacheNullValues = cacheNullValues;
}

public String getAllowedPackage() {
return allowedPackage;
}

public void setAllowedPackage(String allowedPackage) {
this.allowedPackage = allowedPackage;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package kr.devslab.kit.cache.core;

import java.time.Duration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import tools.jackson.databind.jsontype.PolymorphicTypeValidator;

/**
* Redis-backed {@link CacheManager} with kit-owned JSON serialization
* (ADR 0002 §3). A consumer sets {@code devslab.kit.cache.type=redis}, adds
* {@code spring-boot-starter-data-redis}, and never touches serialization —
* values are stored as JSON, no {@code Serializable} requirement, no
* {@code SerializationException}.
*
* <p>Values are serialized with {@link GenericJacksonJsonRedisSerializer} — the
* Jackson-3 generic serializer (Spring Boot 4 is on Jackson 3 / {@code tools.jackson};
* the older {@code GenericJackson2JsonRedisSerializer} is Jackson-2 based and
* deprecated in Spring Data Redis 4, so it's deliberately not used). The
* serializer is built with default typing enabled so type hints are embedded in
* the JSON: that's what lets a cached {@code record} read back as the same
* record rather than a {@code Map} (Spring's {@code RedisCache} deserializes
* without a target type, so the type has to live in the payload).
*
* <p>Default typing is the CVE-sensitive part of JSON deserialization, so it's
* constrained by a {@link BasicPolymorphicTypeValidator} allow-list — never the
* unsafe/laissez-faire variant. The allow-list covers {@code java.*} (the
* component types of collections, records, dates) plus the consumer's base
* package, taken from {@code devslab.kit.cache.allowed-package} (default
* {@code kr.devslab}). A consumer caching their own types sets that property to
* their root package; if they need finer control they can define their own
* {@code CacheManager} bean and the kit backs off entirely.
*
* <p>This class is named {@code DevslabKitRedisCacheConfiguration} — NOT
* {@code RedisCacheConfiguration} — on purpose: Spring Data Redis's own
* {@link RedisCacheConfiguration} (imported above) is the cache-defaults
* builder, and a same-name class would shadow that import.
*
* <p>Guarded by {@link ConditionalOnClass} on {@link RedisConnectionFactory}:
* with Redis off the classpath this whole configuration is skipped, so the
* {@code compileOnly} Redis dependency never leaks onto an in-memory consumer.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RedisConnectionFactory.class)
@ConditionalOnProperty(prefix = "devslab.kit.cache", name = "type", havingValue = "redis")
class DevslabKitRedisCacheConfiguration {

@Bean
@ConditionalOnMissingBean(CacheManager.class)
CacheManager devslabKitRedisCacheManager(
RedisConnectionFactory connectionFactory,
CacheProperties properties
) {
PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("java.")
.allowIfSubType(properties.getAllowedPackage())
.build();

// Declared as RedisSerializer<Object> (a plain widening conversion —
// GenericJacksonJsonRedisSerializer implements RedisSerializer<Object>)
// so SerializationPair.fromSerializer infers the value type cleanly.
RedisSerializer<Object> valueSerializer =
GenericJacksonJsonRedisSerializer.builder()
.enableDefaultTyping(validator)
.build();

RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(valueSerializer))
.prefixCacheNameWith(properties.getKeyPrefix());

Duration ttl = properties.getTtl();
if (ttl != null && !ttl.isZero() && !ttl.isNegative()) {
config = config.entryTtl(ttl);
}
if (!properties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}

return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package kr.devslab.kit.cache.core;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

/**
* Proves the headline claim of ADR 0002: with {@code type=redis} a consumer's
* value round-trips through real Redis as JSON with its concrete type intact —
* no {@code Serializable}, no serializer config, no {@code SerializationException}.
*
* <p>Runs against a real Redis (Testcontainers) because serialization is the
* whole risk; a mock would prove nothing. The connection factory is created and
* started <em>per test</em> ({@link BeforeEach}), not once in a static
* {@code @BeforeAll}: a {@link LettuceConnectionFactory} built in a static
* context doesn't reliably finish its async client init before the cache's first
* write, which silently drops it (an isolated diagnostic confirmed the
* serializer + cache + Redis I/O are otherwise correct). Per-test setup gives
* each test a freshly-started, fully-initialized factory.
*
* <p>Drives the kit's {@link DevslabKitRedisCacheConfiguration} bean method
* directly with a real {@link LettuceConnectionFactory} — deliberately bypassing
* Spring Boot's autoconfiguration. The config class is package-private; this
* test shares its package.
*/
@Testcontainers
class RedisCacheRoundTripTest {

@Container
static final GenericContainer<?> REDIS =
new GenericContainer<>(DockerImageName.parse("redis:7-alpine")).withExposedPorts(6379);

private LettuceConnectionFactory connectionFactory;
private CacheManager cacheManager;

@BeforeEach
void setUp() {
connectionFactory = new LettuceConnectionFactory(REDIS.getHost(), REDIS.getMappedPort(6379));
connectionFactory.afterPropertiesSet();
connectionFactory.start();

cacheManager = new DevslabKitRedisCacheConfiguration()
.devslabKitRedisCacheManager(connectionFactory, new CacheProperties());
}

@AfterEach
void tearDown() {
if (connectionFactory != null) {
connectionFactory.destroy();
}
}

/** A record — the classic type JDK serialization / a forgotten Serializable trips on. */
record Customer(String id, String name, int visits) {
}

@Test
void buildsRedisCacheManager() {
assertThat(cacheManager).isInstanceOf(RedisCacheManager.class);
}

@Test
void recordRoundTripsThroughRealRedisWithItsConcreteType() {
Cache cache = cacheManager.getCache("customers");
assertThat(cache).isNotNull();

Customer original = new Customer("c-1", "Aisha", 7);
cache.put("c-1", original);

Customer fromCache = cache.get("c-1", Customer.class);
assertThat(fromCache).isEqualTo(original);
assertThat(fromCache.name()).isEqualTo("Aisha");
assertThat(fromCache.visits()).isEqualTo(7);
}

@Test
void stringRoundTrips() {
Cache cache = cacheManager.getCache("strings");
assertThat(cache).isNotNull();
cache.put("k", "hello-redis");
assertThat(cache.get("k", String.class)).isEqualTo("hello-redis");
}

@Test
void missReturnsNull() {
Cache cache = cacheManager.getCache("customers");
assertThat(cache).isNotNull();
assertThat(cache.get("absent-key-" + System.nanoTime())).isNull();
}
}
Loading