diff --git a/pom.xml b/pom.xml index ff0ef865..ad886a64 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ not datatype, data format, or JAX-RS provider modules. mrbean osgi paranamer + subtype no-ctor-deser diff --git a/subtype/README.md b/subtype/README.md new file mode 100644 index 00000000..b23475c6 --- /dev/null +++ b/subtype/README.md @@ -0,0 +1,66 @@ +# jackson-module-subtype + +Registering subtypes without annotating the parent class, +see [this](https://github.com/FasterXML/jackson-databind/issues/2104). + +Implementation on SPI. + +# Usage + +Registering modules. + +``` +ObjectMapper mapper = new ObjectMapper().registerModule(new SubtypeModule()); +``` + +Ensure that the parent class has at least the `JsonTypeInfo` annotation. + +```java +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +public interface Parent { +} +``` + +1. add the `JsonSubType` annotation to your subclass. +2. provide a non-argument constructor (SPI require it). + +```java +import com.fasterxml.jackson.module.subtype.JsonSubType; + +@JsonSubType("first-child") +public class FirstChild { + + private String foo; + // ... + + public FirstChild() { + } +} +``` + +SPI: Put the subclasses in the `META-INF/services` directory under the interface. +Example: `META-INF/services/package.Parent` + +``` +package.FirstChild +``` + +Alternatively, you can also use the `auto-service` to auto-generate these files: + +```java +import io.github.black.jackson.JsonSubType; +import com.google.auto.service.AutoService; + +@AutoService(Parent.class) +@JsonSubType("first-child") +public class FirstChild { + + private String foo; + // ... + + public FirstChild() { + } +} +``` + +Done, enjoy it. \ No newline at end of file diff --git a/subtype/pom.xml b/subtype/pom.xml new file mode 100644 index 00000000..99c92189 --- /dev/null +++ b/subtype/pom.xml @@ -0,0 +1,76 @@ + + + + + + + + 4.0.0 + + com.fasterxml.jackson.module + jackson-modules-base + 2.16.0-SNAPSHOT + + jackson-module-subtype + Jackson module: Subtype Annotation Support + bundle + + Registering subtypes without annotating the parent class + https://github.com/FasterXML/jackson-modules-base + + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + com/fasterxml/jackson/module/subtype + com.fasterxml.jackson.module.subtype + + + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + com.google.auto.service + auto-service + 1.0.1 + test + + + + + + + com.google.code.maven-replacer-plugin + replacer + + + + org.moditect + moditect-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + 9 + 9 + + + + + \ No newline at end of file diff --git a/subtype/src/main/java/com/fasterxml/jackson/module/subtype/JsonSubType.java b/subtype/src/main/java/com/fasterxml/jackson/module/subtype/JsonSubType.java new file mode 100644 index 00000000..842d17bb --- /dev/null +++ b/subtype/src/main/java/com/fasterxml/jackson/module/subtype/JsonSubType.java @@ -0,0 +1,40 @@ +package com.fasterxml.jackson.module.subtype; + +import com.fasterxml.jackson.annotation.JacksonAnnotation; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeName; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Definition of a subtype, along with optional name(s). If no name is defined + * (empty Strings are ignored), class of the type will be checked for {@link JsonTypeName} + * annotation; and if that is also missing or empty, a default + * name will be constructed by type id mechanism. + * Default name is usually based on class name. + *

+ * It's the same as {@link JsonSubTypes.Type}. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotation +public @interface JsonSubType { + /** + * Logical type name used as the type identifier for the class, if defined; empty + * String means "not defined". Used unless {@link #names} is defined as non-empty. + * + * @return subtype name + */ + String value() default ""; + + /** + * (optional) Logical type names used as the type identifier for the class: used if + * more than one type name should be associated with the same type. + * + * @return subtype name array + */ + String[] names() default {}; +} diff --git a/subtype/src/main/java/com/fasterxml/jackson/module/subtype/PackageVersion.java.in b/subtype/src/main/java/com/fasterxml/jackson/module/subtype/PackageVersion.java.in new file mode 100644 index 00000000..7860aa14 --- /dev/null +++ b/subtype/src/main/java/com/fasterxml/jackson/module/subtype/PackageVersion.java.in @@ -0,0 +1,20 @@ +package @package@; + +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.core.Versioned; +import com.fasterxml.jackson.core.util.VersionUtil; + +/** + * Automatically generated from PackageVersion.java.in during + * packageVersion-generate execution of maven-replacer-plugin in + * pom.xml. + */ +public final class PackageVersion implements Versioned { + public final static Version VERSION = VersionUtil.parseVersion( + "@projectversion@", "@projectgroupid@", "@projectartifactid@"); + + @Override + public Version version() { + return VERSION; + } +} diff --git a/subtype/src/main/java/com/fasterxml/jackson/module/subtype/SubtypeModule.java b/subtype/src/main/java/com/fasterxml/jackson/module/subtype/SubtypeModule.java new file mode 100644 index 00000000..9b18605c --- /dev/null +++ b/subtype/src/main/java/com/fasterxml/jackson/module/subtype/SubtypeModule.java @@ -0,0 +1,122 @@ +package com.fasterxml.jackson.module.subtype; + +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.databind.AnnotationIntrospector; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.introspect.Annotated; +import com.fasterxml.jackson.databind.jsontype.NamedType; +import com.fasterxml.jackson.module.subtype.PackageVersion; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.ServiceLoader; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * Subtype module. + *

+ * The module caches the subclass, so it's non-real-time. + * It's for registering subtypes without annotating the parent class. + * See this issues in jackson-databind. + *

+ * When not found in the cache, it loads and caches subclasses using SPI. + * Therefore, we can {@link #unregisterType} a class and then module will reload this class's subclasses. + * + * @since 2.16 + */ +public class SubtypeModule extends Module { + + private final ConcurrentHashMap, List> subtypes = new ConcurrentHashMap<>(); + + @Override + public String getModuleName() { + return getClass().getSimpleName(); + } + + @Override + public Version version() { + return PackageVersion.VERSION; + } + + @Override + public void setupModule(SetupContext context) { + context.insertAnnotationIntrospector(new AnnotationIntrospector() { + @Override + public Version version() { + return PackageVersion.VERSION; + } + + @Override + public List findSubtypes(Annotated a) { + registerTypes(a.getRawType()); + + List list1 = _findSubtypes(a.getRawType(), a::getAnnotation); + List list2 = subtypes.getOrDefault(a.getRawType(), Collections.emptyList()); + + if (list1.isEmpty()) return list2; + if (list2.isEmpty()) return list1; + List list = new ArrayList<>(list1.size() + list2.size()); + list.addAll(list1); + list.addAll(list2); + return list; + } + }); + } + + /** + * load parent's subclass by SPI. + * + * @param parent parent class. + * @param parent class type. + */ + @SuppressWarnings("unchecked") + public void registerTypes(Class parent) { + if (subtypes.containsKey(parent)) { + return; + } + List> subclasses = new ArrayList<>(); + for (S instance : ServiceLoader.load(parent)) { + subclasses.add((Class) instance.getClass()); + } + this.registerTypes(parent, subclasses); + } + + /** + * register subtypes without SPI. + * Of course, you need to provide them :) + * + * @param parent: parent class. + * @param subclasses: children class. + * @param : parent class type. + */ + public void registerTypes(Class parent, Iterable> subclasses) { + List result = new ArrayList<>(); + for (Class subclass : subclasses) { + result.addAll(_findSubtypes(subclass, subclass::getAnnotation)); + } + subtypes.put(parent, result); + } + + public void unregisterType(Class parent) { + subtypes.remove(parent); + } + + private List _findSubtypes(Class clazz, Function, JsonSubType> getter) { + if (clazz == null) { + return Collections.emptyList(); + } + JsonSubType subtype = getter.apply(JsonSubType.class); + if (subtype == null) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + result.add(new NamedType(clazz, subtype.value())); + // [databind#2761]: alternative set of names to use + for (String name : subtype.names()) { + result.add(new NamedType(clazz, name)); + } + return result; + } +} diff --git a/subtype/src/main/resources/META-INF/LICENSE b/subtype/src/main/resources/META-INF/LICENSE new file mode 100644 index 00000000..a9e54621 --- /dev/null +++ b/subtype/src/main/resources/META-INF/LICENSE @@ -0,0 +1,8 @@ +This copy of Jackson JSON processor `jackson-module-guice` module is licensed under the +Apache (Software) License, version 2.0 ("the License"). +See the License for details about distribution rights, and the +specific rights regarding derivative works. + +You may obtain a copy of the License at: + +http://www.apache.org/licenses/LICENSE-2.0 diff --git a/subtype/src/main/resources/META-INF/NOTICE b/subtype/src/main/resources/META-INF/NOTICE new file mode 100644 index 00000000..4c976b7b --- /dev/null +++ b/subtype/src/main/resources/META-INF/NOTICE @@ -0,0 +1,20 @@ +# Jackson JSON processor + +Jackson is a high-performance, Free/Open Source JSON processing library. +It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has +been in development since 2007. +It is currently developed by a community of developers, as well as supported +commercially by FasterXML.com. + +## Licensing + +Jackson core and extension components may licensed under different licenses. +To find the details that apply to this artifact see the accompanying LICENSE file. +For more information, including possible other licensing options, contact +FasterXML.com (http://fasterxml.com). + +## Credits + +A list of contributors may be found from CREDITS file, which is included +in some artifacts (usually source distributions); but is always available +from the source code management (SCM) system project uses. diff --git a/subtype/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/subtype/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module new file mode 100644 index 00000000..f0d3925e --- /dev/null +++ b/subtype/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module @@ -0,0 +1 @@ +com.fasterxml.jackson.module.subtype.SubtypeModule \ No newline at end of file diff --git a/subtype/src/moditect/module-info.java b/subtype/src/moditect/module-info.java new file mode 100644 index 00000000..4988481f --- /dev/null +++ b/subtype/src/moditect/module-info.java @@ -0,0 +1,8 @@ +module com.fasterxml.jackson.module.subtype { + + requires com.fasterxml.jackson.core; + requires com.fasterxml.jackson.annotation; + requires com.fasterxml.jackson.databind; + + exports com.fasterxml.jackson.module.subtype; +} diff --git a/subtype/src/test/java/com/fasterxml/jackson/module/subtype/WithJsonSubTypesTest.java b/subtype/src/test/java/com/fasterxml/jackson/module/subtype/WithJsonSubTypesTest.java new file mode 100644 index 00000000..eb3e665d --- /dev/null +++ b/subtype/src/test/java/com/fasterxml/jackson/module/subtype/WithJsonSubTypesTest.java @@ -0,0 +1,223 @@ +package com.fasterxml.jackson.module.subtype; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auto.service.AutoService; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +import static org.junit.Assert.*; + +/** + * test {@link JsonSubType} work with {@link JsonSubTypes} + */ +@RunWith(value = Parameterized.class) +public class WithJsonSubTypesTest { + + private final ObjectMapper mapper = new ObjectMapper().registerModule(new SubtypeModule()); + + public static class Argument { + private final Class clazz; + private final T expected; + + public Argument(Class clazz, T expected) { + this.clazz = clazz; + this.expected = expected; + } + } + + @Parameter + public Argument argument; + + @Parameters + public static Collection> data() { + return Arrays.asList( + new Argument<>(FirstChild.class, new FirstChild("hello")), + new Argument<>(SecondChild.class, new SecondChild("world")), + new Argument<>(FirstAppendChild.class, new FirstAppendChild(42)), + new Argument<>(SecondAppendChild.class, new SecondAppendChild("42", Arrays.asList("hello", "foo", "bar"))), + new Argument<>(ThirdAppendChild.class, new ThirdAppendChild("42", Arrays.asList("hello", "foo", "bar"), 3.1415926)) + ); + } + + @Test + public void test() throws Exception { + final Parent parent = argument.expected; + String json = mapper.writeValueAsString(parent); + Parent unmarshal = mapper.readValue(json, Parent.class); + T actual = assertInstanceOf(argument.clazz, unmarshal); + assertEquals(argument.expected, actual); + } + + public static T assertInstanceOf(Class expectedType, Object actualValue) { + assertTrue(expectedType.isInstance(actualValue)); + return expectedType.cast(actualValue); + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") + @JsonSubTypes(value = { + @JsonSubTypes.Type(value = FirstChild.class, name = "first-child"), + @JsonSubTypes.Type(value = SecondChild.class, name = "second-child"), + }) + public interface Parent { + } + + public static class FirstChild implements Parent { + public String foo; + + @SuppressWarnings("unused") // SPI require it + public FirstChild() { + } + + public FirstChild(String foo) { + this.foo = foo; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FirstChild that = (FirstChild) o; + + return Objects.equals(foo, that.foo); + } + + @Override + public int hashCode() { + return foo != null ? foo.hashCode() : 0; + } + } + + public static class SecondChild implements Parent { + public String bar; + + public SecondChild() { + } + + public SecondChild(String bar) { + this.bar = bar; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + SecondChild that = (SecondChild) o; + + return Objects.equals(bar, that.bar); + } + + @Override + public int hashCode() { + return bar != null ? bar.hashCode() : 0; + } + } + + @JsonSubType("first-append-child") + @AutoService(Parent.class) + public static class FirstAppendChild implements Parent { + public Integer integer; + + @SuppressWarnings("unused") // SPI require it + public FirstAppendChild() { + } + + public FirstAppendChild(Integer integer) { + this.integer = integer; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FirstAppendChild that = (FirstAppendChild) o; + + return Objects.equals(integer, that.integer); + } + + @Override + public int hashCode() { + return integer != null ? integer.hashCode() : 0; + } + } + + + @JsonSubType("second-append-child") + @AutoService(Parent.class) + public static class SecondAppendChild extends SecondChild { + public List list; + + public SecondAppendChild() { + } + + public SecondAppendChild(String bar, List list) { + super(bar); + this.list = list; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + + SecondAppendChild that = (SecondAppendChild) o; + + return Objects.equals(list, that.list); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + (list != null ? list.hashCode() : 0); + return result; + } + } + + @JsonSubType("third-append-child") + @AutoService(Parent.class) + public static class ThirdAppendChild extends SecondAppendChild { + public double value; + + @SuppressWarnings("unused") // SPI require it + public ThirdAppendChild() { + } + + public ThirdAppendChild(String bar, List list, double value) { + super(bar, list); + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + + ThirdAppendChild that = (ThirdAppendChild) o; + + return Double.compare(value, that.value) == 0; + } + + @Override + public int hashCode() { + int result = super.hashCode(); + long temp; + temp = Double.doubleToLongBits(value); + result = 31 * result + (int) (temp ^ (temp >>> 32)); + return result; + } + } +} \ No newline at end of file diff --git a/subtype/src/test/java/com/fasterxml/jackson/module/subtype/WithoutJsonSubTypesTest.java b/subtype/src/test/java/com/fasterxml/jackson/module/subtype/WithoutJsonSubTypesTest.java new file mode 100644 index 00000000..77056f9a --- /dev/null +++ b/subtype/src/test/java/com/fasterxml/jackson/module/subtype/WithoutJsonSubTypesTest.java @@ -0,0 +1,105 @@ +package com.fasterxml.jackson.module.subtype; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auto.service.AutoService; +import org.junit.Test; + +import java.util.Objects; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * test {@link JsonSubType} works alone, without {@link JsonSubTypes} + */ +public class WithoutJsonSubTypesTest { + private final ObjectMapper mapper = new ObjectMapper().registerModule(new SubtypeModule()); + + @Test + public void testFirstChild() throws Exception { + FirstChild child = new FirstChild(); + child.foo = "hello"; + String json = mapper.writeValueAsString(child); + + // {"type":"first-child","foo":"hello"} + + Parent unmarshal = mapper.readValue(json, Parent.class); + FirstChild actual = assertInstanceOf(FirstChild.class, unmarshal); + assertEquals("hello", actual.foo); + } + + @Test + public void testSecondChild() throws Exception { + SecondChild child = new SecondChild(); + child.bar = "world"; + String json = mapper.writeValueAsString(child); + + // {"type":"second-child","bar":"world"} + + Parent unmarshal = mapper.readValue(json, Parent.class); + SecondChild actual = assertInstanceOf(SecondChild.class, unmarshal); + assertEquals("world", actual.bar); + } + + public static T assertInstanceOf(Class expectedType, Object actualValue) { + assertTrue(expectedType.isInstance(actualValue)); + return expectedType.cast(actualValue); + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") + public interface Parent { + } + + @JsonSubType("first-child") + @AutoService(Parent.class) // module requires spi + public static class FirstChild implements Parent { + public String foo; + + public FirstChild() { + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FirstChild that = (FirstChild) o; + + return Objects.equals(foo, that.foo); + } + + @Override + public int hashCode() { + return foo != null ? foo.hashCode() : 0; + } + } + + + @JsonSubType("second-child") + @AutoService(Parent.class) // module requires spi + public static class SecondChild implements Parent { + public String bar; + + public SecondChild() { + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + SecondChild that = (SecondChild) o; + + return Objects.equals(bar, that.bar); + } + + @Override + public int hashCode() { + return bar != null ? bar.hashCode() : 0; + } + } + + +} \ No newline at end of file