diff --git a/build.gradle.kts b/build.gradle.kts index 149e7c16..71eddbc9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -47,6 +47,7 @@ subprojects { dependencies { // annotations compileOnly("org.jetbrains:annotations:24.1.0") + testCompileOnly("org.jetbrains:annotations:24.1.0") // tests testImplementation(platform("org.junit:junit-bom:5.10.1")) diff --git a/jackson/build.gradle.kts b/jackson/build.gradle.kts new file mode 100644 index 00000000..80aacc3b --- /dev/null +++ b/jackson/build.gradle.kts @@ -0,0 +1,16 @@ +dependencies { + api(project(":cache-core")) + implementation("com.fasterxml.jackson.core:jackson-databind") { + version { + require("2.16.0") // imposes a lower bound on acceptable versions + } + } + testImplementation(project(":cache-provider-caffeine")) +} + +publishing.publications.withType { + pom { + name.set("Xanthic - Jackson") + description.set("Xanthic Cache Jackson Adapter") + } +} diff --git a/jackson/src/main/java/io/github/xanthic/jackson/XanthicJacksonCacheAdapter.java b/jackson/src/main/java/io/github/xanthic/jackson/XanthicJacksonCacheAdapter.java new file mode 100644 index 00000000..f42c2959 --- /dev/null +++ b/jackson/src/main/java/io/github/xanthic/jackson/XanthicJacksonCacheAdapter.java @@ -0,0 +1,80 @@ +package io.github.xanthic.jackson; + +import com.fasterxml.jackson.databind.util.LookupCache; +import io.github.xanthic.cache.api.Cache; +import io.github.xanthic.cache.core.CacheApi; +import io.github.xanthic.cache.core.CacheApiSpec; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import org.jetbrains.annotations.NotNull; + +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Wraps a Xanthic {@link Cache} for use as a Jackson {@link LookupCache}. + *

+ * Most users should utilize {@link XanthicJacksonCacheProvider} rather than directly interact with this class. + * + * @param The type of keys that form the cache + * @param The type of values contained in the cache + */ +@Value +@RequiredArgsConstructor +public class XanthicJacksonCacheAdapter implements LookupCache { + + /** + * The Xanthic cache to use as a Jackson {@link LookupCache}. + */ + Cache cache; + + /** + * The specification associated with the constructed cache. + */ + Consumer> spec; + + /** + * Creates a Jackson {@link LookupCache} by wrapping a Xanthic cache with this adapter. + * + * @param spec the cache specification (note: specifying {@link CacheApiSpec#maxSize(Long)} is recommended) + */ + public XanthicJacksonCacheAdapter(@NotNull Consumer> spec) { + this(CacheApi.create(spec), spec); + } + + @Override + public int size() { + return (int) cache.size(); + } + + @Override + @SuppressWarnings("unchecked") + public V get(Object key) { + return cache.get((K) key); + } + + @Override + public V put(K key, V value) { + return cache.put(key, value); + } + + @Override + public V putIfAbsent(K key, V value) { + return cache.putIfAbsent(key, value); + } + + @Override + public void clear() { + cache.clear(); + } + + @Override + public void contents(BiConsumer consumer) { + cache.forEach(consumer); + } + + @Override + public XanthicJacksonCacheAdapter emptyCopy() { + return new XanthicJacksonCacheAdapter<>(spec); + } +} diff --git a/jackson/src/main/java/io/github/xanthic/jackson/XanthicJacksonCacheProvider.java b/jackson/src/main/java/io/github/xanthic/jackson/XanthicJacksonCacheProvider.java new file mode 100644 index 00000000..297ed3b6 --- /dev/null +++ b/jackson/src/main/java/io/github/xanthic/jackson/XanthicJacksonCacheProvider.java @@ -0,0 +1,91 @@ +package io.github.xanthic.jackson; + +import com.fasterxml.jackson.databind.DeserializationConfig; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.cfg.CacheProvider; +import com.fasterxml.jackson.databind.deser.DeserializerCache; +import com.fasterxml.jackson.databind.ser.SerializerCache; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.databind.util.LookupCache; +import com.fasterxml.jackson.databind.util.TypeKey; +import io.github.xanthic.cache.core.CacheApiSpec; +import io.github.xanthic.jackson.util.SerializableConsumer; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Value; + +/** + * Implementation of Jackson's {@link CacheProvider} that yields Xanthic {@link io.github.xanthic.cache.api.Cache} instances, + * which are backed by any cache implementation of your choosing. + *

+ * Example usage: + * {@code ObjectMapper mapper = JsonMapper.builder().cacheProvider(XanthicJacksonCacheProvider.defaultInstance()).build(); } + */ +@Value +@Getter(AccessLevel.PRIVATE) +@RequiredArgsConstructor +public class XanthicJacksonCacheProvider implements CacheProvider { + private static final long serialVersionUID = 1L; + private static final XanthicJacksonCacheProvider DEFAULT_INSTANCE = new XanthicJacksonCacheProvider(); + + /** + * Specification for the deserializer cache. + */ + SerializableConsumer>> deserializationSpec; + + /** + * Specification for the serializer cache. + */ + SerializableConsumer>> serializationSpec; + + /** + * Specification for the type factory cache. + */ + SerializableConsumer> typeFactorySpec; + + /** + * Creates a Jackson {@link CacheProvider} backed by Xanthic, using the specified max cache sizes. + * + * @param maxDeserializerCacheSize the maximum size of the deserializer cache + * @param maxSerializerCacheSize the maximum size of the serializer cache + * @param maxTypeFactoryCacheSize the maximum size of the type factory cache + */ + public XanthicJacksonCacheProvider(long maxDeserializerCacheSize, long maxSerializerCacheSize, long maxTypeFactoryCacheSize) { + this.deserializationSpec = spec -> spec.maxSize(maxDeserializerCacheSize); + this.serializationSpec = spec -> spec.maxSize(maxSerializerCacheSize); + this.typeFactorySpec = spec -> spec.maxSize(maxTypeFactoryCacheSize); + } + + /** + * Creates a Jackson {@link CacheProvider} backed by Xanthic, using Jackson's recommended default max cache sizes. + */ + private XanthicJacksonCacheProvider() { + this(DeserializerCache.DEFAULT_MAX_CACHE_SIZE, SerializerCache.DEFAULT_MAX_CACHE_SIZE, TypeFactory.DEFAULT_MAX_CACHE_SIZE); + } + + @Override + public LookupCache> forDeserializerCache(DeserializationConfig config) { + return new XanthicJacksonCacheAdapter<>(deserializationSpec); + } + + @Override + public LookupCache> forSerializerCache(SerializationConfig config) { + return new XanthicJacksonCacheAdapter<>(serializationSpec); + } + + @Override + public LookupCache forTypeFactory() { + return new XanthicJacksonCacheAdapter<>(typeFactorySpec); + } + + /** + * @return a Jackson {@link CacheProvider} backed by Xanthic, using Jackson's recommended default max cache sizes. + */ + public static XanthicJacksonCacheProvider defaultInstance() { + return DEFAULT_INSTANCE; + } +} diff --git a/jackson/src/main/java/io/github/xanthic/jackson/util/SerializableConsumer.java b/jackson/src/main/java/io/github/xanthic/jackson/util/SerializableConsumer.java new file mode 100644 index 00000000..468a98de --- /dev/null +++ b/jackson/src/main/java/io/github/xanthic/jackson/util/SerializableConsumer.java @@ -0,0 +1,13 @@ +package io.github.xanthic.jackson.util; + +import java.io.Serializable; +import java.util.function.Consumer; + +/** + * A serializable {@link Consumer} since {@link com.fasterxml.jackson.databind.cfg.CacheProvider} + * must be {@link Serializable}, as it is stored in {@link com.fasterxml.jackson.databind.ObjectMapper}. + * + * @param the type of the input for the consumer + */ +@FunctionalInterface +public interface SerializableConsumer extends Consumer, Serializable {} diff --git a/jackson/src/test/java/io/github/xanthic/jackson/XanthicJacksonCacheProviderTest.java b/jackson/src/test/java/io/github/xanthic/jackson/XanthicJacksonCacheProviderTest.java new file mode 100644 index 00000000..58fcc05e --- /dev/null +++ b/jackson/src/test/java/io/github/xanthic/jackson/XanthicJacksonCacheProviderTest.java @@ -0,0 +1,132 @@ +package io.github.xanthic.jackson; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.cfg.CacheProvider; +import com.fasterxml.jackson.databind.deser.DeserializerCache; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.ser.SerializerCache; +import com.fasterxml.jackson.databind.type.TypeFactory; +import io.github.xanthic.jackson.util.TrackedCacheProvider; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class XanthicJacksonCacheProviderTest { + + @Test + void defaults() throws JsonProcessingException { + ObjectMapper mapper = JsonMapper.builder() + .cacheProvider(XanthicJacksonCacheProvider.defaultInstance()) + .build(); + assertNotNull(mapper.readValue("{\"bar\":\"baz\"}", Foo.class)); + assertNotNull(mapper.writeValueAsString(new Foo("baz"))); + assertNotNull(mapper.getTypeFactory().constructParametricType(List.class, Integer.class)); + } + + @Test + void deserialize() throws JsonProcessingException { + TrackedCacheProvider provider = new TrackedCacheProvider(); + ObjectMapper mapper = JsonMapper.builder() + .cacheProvider(createCacheProvider(provider)) + .build(); + assertFalse(provider.getConstructedCaches().stream().anyMatch(c -> c.size() > 0)); + Foo foo = mapper.readValue("{\"bar\":\"baz\"}", Foo.class); + assertNotNull(foo); + assertEquals("baz", foo.getBar()); + assertTrue(provider.getConstructedCaches().stream().anyMatch(c -> c.size() > 0)); + } + + @Test + void serialize() throws JsonProcessingException { + TrackedCacheProvider provider = new TrackedCacheProvider(); + ObjectMapper mapper = JsonMapper.builder() + .cacheProvider(createCacheProvider(provider)) + .build(); + assertFalse(provider.getConstructedCaches().stream().anyMatch(c -> c.size() > 0)); + String json = mapper.writeValueAsString(new Foo("baz")); + assertEquals("{\"bar\":\"baz\"}", json); + assertTrue(provider.getConstructedCaches().stream().anyMatch(c -> c.size() > 0)); + } + + @Test + void constructType() { + TrackedCacheProvider provider = new TrackedCacheProvider(); + ObjectMapper mapper = JsonMapper.builder() + .cacheProvider(createCacheProvider(provider)) + .build(); + assertFalse(provider.getConstructedCaches().stream().anyMatch(c -> c.size() > 0)); + JavaType type = mapper.getTypeFactory().constructParametricType(List.class, Integer.class); + assertNotNull(type); + assertTrue(provider.getConstructedCaches().stream().anyMatch(c -> c.size() > 0)); + } + + @Test + void constructMultiple() throws JsonProcessingException { + TrackedCacheProvider provider = new TrackedCacheProvider(); + assertEquals(0, provider.getConstructedCaches().size()); + + ObjectMapper m1 = JsonMapper.builder().cacheProvider(createCacheProvider(provider)).build(); + m1.readValue("{\"bar\":\"baz\"}", Foo.class); + m1.writeValueAsString(new Foo("baz")); + m1.getTypeFactory().constructParametricType(List.class, Integer.class); + assertEquals(3, provider.getConstructedCaches().size()); + assertTrue(provider.getConstructedCaches().stream().allMatch(c -> c.size() > 0)); + + ObjectMapper m2 = JsonMapper.builder().cacheProvider(createCacheProvider(provider)).build(); + m2.readValue("{\"bar\":\"baz\"}", Foo.class); + m2.writeValueAsString(new Foo("baz")); + m2.getTypeFactory().constructParametricType(List.class, Integer.class); + assertEquals(6, provider.getConstructedCaches().size()); + assertTrue(provider.getConstructedCaches().stream().allMatch(c -> c.size() > 0)); + } + + @Test + void serializable() throws IOException, ClassNotFoundException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(XanthicJacksonCacheProvider.defaultInstance()); + } + + XanthicJacksonCacheProvider provider; + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + provider = (XanthicJacksonCacheProvider) ois.readObject(); + } + + assertNotNull(provider); + assertNotNull(provider.forTypeFactory()); + } + + private static CacheProvider createCacheProvider(TrackedCacheProvider trackedProvider) { + return new XanthicJacksonCacheProvider( + spec -> spec.provider(trackedProvider).maxSize((long) DeserializerCache.DEFAULT_MAX_CACHE_SIZE), + spec -> spec.provider(trackedProvider).maxSize((long) SerializerCache.DEFAULT_MAX_CACHE_SIZE), + spec -> spec.provider(trackedProvider).maxSize((long) TypeFactory.DEFAULT_MAX_CACHE_SIZE) + ); + } + + @Data + @Setter(AccessLevel.PRIVATE) + @NoArgsConstructor + @AllArgsConstructor + static class Foo { + private String bar; + } + +} diff --git a/jackson/src/test/java/io/github/xanthic/jackson/util/TrackedCacheProvider.java b/jackson/src/test/java/io/github/xanthic/jackson/util/TrackedCacheProvider.java new file mode 100644 index 00000000..3950bda3 --- /dev/null +++ b/jackson/src/test/java/io/github/xanthic/jackson/util/TrackedCacheProvider.java @@ -0,0 +1,23 @@ +package io.github.xanthic.jackson.util; + +import io.github.xanthic.cache.api.Cache; +import io.github.xanthic.cache.api.CacheProvider; +import io.github.xanthic.cache.api.ICacheSpec; +import io.github.xanthic.cache.core.CacheApiSettings; +import lombok.Value; + +import java.util.ArrayList; +import java.util.List; + +@Value +public class TrackedCacheProvider implements CacheProvider { + CacheProvider underlyingProvider = CacheApiSettings.getInstance().getDefaultCacheProvider(); + List> constructedCaches = new ArrayList<>(); + + @Override + public Cache build(ICacheSpec spec) { + Cache cache = underlyingProvider.build(spec); + constructedCaches.add(cache); + return cache; + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 33230989..de55c181 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,7 @@ include( ":api", ":core", ":kotlin", + ":jackson", ":spring", ":spring-java17", ":provider-androidx", @@ -22,6 +23,7 @@ project(":bom").name = "cache-bom" project(":api").name = "cache-api" project(":core").name = "cache-core" project(":kotlin").name = "cache-kotlin" +project(":jackson").name = "cache-jackson" project(":spring").name = "cache-spring" project(":spring-java17").name = "cache-spring-java17" project(":provider-androidx").name = "cache-provider-androidx"