This is an automated email from the ASF dual-hosted git repository. rmannibucau pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/johnzon.git
The following commit(s) were added to refs/heads/master by this push: new 9080e292 Implement JSON-B 3 Polymorphism (#100) 9080e292 is described below commit 9080e292749b5157cae0ad6b0e7c1de3a7ace275 Author: Markus Jung <54570207+ju...@users.noreply.github.com> AuthorDate: Thu Apr 20 13:54:34 2023 +0200 Implement JSON-B 3 Polymorphism (#100) * Implement JSON-B 3 polymorphism (WIP) * Implement more JSON-B 3 polymorphism validations * JSON-B 3 polymorphism tests * Allow JsonbSubtype for type=annotated type * MapperBuilder#setPolymorphismHandler javadoc * use JsonbRule in tests * Allow custom Mappings to be used * Implement JsonbMappings for polymorphism * create Mappings via Function instead of using reflection * use Meta.getAnnotation instead of Class.getAnnotation * restore old ClassMapping constructor for better backwards compatibility * update docs on jsonb-extras polymorphism * restore MapperConfig constructor * cache JsonbTypeInfos to avoid reflections --- .../org/apache/johnzon/jsonb/JohnzonBuilder.java | 9 +- .../org/apache/johnzon/jsonb/JsonbMappings.java | 74 ++++++++ .../polymorphism/JsonbPolymorphismHandler.java | 203 +++++++++++++++++++++ .../polymorphism/JsonbPolymorphismTypeInfo.java | 46 +++++ .../jsonb/polymorphism/JsonbPolymorphismTest.java | 92 ++++++++++ .../JsonbPolymorphismValidationTest.java | 143 +++++++++++++++ .../java/org/apache/johnzon/mapper/Mapper.java | 2 +- .../org/apache/johnzon/mapper/MapperBuilder.java | 10 +- .../org/apache/johnzon/mapper/MapperConfig.java | 13 +- .../johnzon/mapper/MappingGeneratorImpl.java | 29 +-- .../apache/johnzon/mapper/MappingParserImpl.java | 14 +- .../java/org/apache/johnzon/mapper/Mappings.java | 24 ++- .../org/apache/johnzon/mapper/access/Meta.java | 2 +- .../test/java/org/superbiz/ExtendMappingTest.java | 4 +- pom.xml | 4 +- src/site/markdown/index.md | 4 +- 16 files changed, 638 insertions(+), 35 deletions(-) diff --git a/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/JohnzonBuilder.java b/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/JohnzonBuilder.java index 4e32ed97..278e814a 100644 --- a/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/JohnzonBuilder.java +++ b/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/JohnzonBuilder.java @@ -31,6 +31,7 @@ import org.apache.johnzon.mapper.Converter; import org.apache.johnzon.mapper.Mapper; import org.apache.johnzon.mapper.MapperBuilder; import org.apache.johnzon.mapper.MapperConfig; +import org.apache.johnzon.mapper.Mappings; import org.apache.johnzon.mapper.ObjectConverter; import org.apache.johnzon.mapper.SerializeValueFilter; import org.apache.johnzon.mapper.access.AccessMode; @@ -58,6 +59,7 @@ import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Stream; @@ -336,6 +338,11 @@ public class JohnzonBuilder implements JsonbBuilder { if (Closeable.class.isInstance(accessMode)) { builder.addCloseable(Closeable.class.cast(accessMode)); } + + builder.setMappingsFactory(config.getProperty("johnzon.mappings-factory") + .map(it -> (Function<MapperConfig, Mappings>) it) + .orElse(JsonbMappings::new)); + return doCreateJsonb(skipCdi, ijson, builder.build()); } @@ -469,4 +476,4 @@ public class JohnzonBuilder implements JsonbBuilder { protected abstract T doCreate(); } -} +} \ No newline at end of file diff --git a/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/JsonbMappings.java b/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/JsonbMappings.java new file mode 100644 index 00000000..b399eb85 --- /dev/null +++ b/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/JsonbMappings.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.johnzon.jsonb; + +import org.apache.johnzon.jsonb.polymorphism.JsonbPolymorphismHandler; +import org.apache.johnzon.mapper.Adapter; +import org.apache.johnzon.mapper.MapperConfig; +import org.apache.johnzon.mapper.Mappings; +import org.apache.johnzon.mapper.ObjectConverter; +import org.apache.johnzon.mapper.access.AccessMode; + +import jakarta.json.JsonObject; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.Map; +import java.util.function.BiFunction; + +public class JsonbMappings extends Mappings { + private final JsonbPolymorphismHandler polymorphismHandler; + + public JsonbMappings(MapperConfig config) { + super(config); + + this.polymorphismHandler = new JsonbPolymorphismHandler(); + } + + @Override + protected Mappings.ClassMapping createClassMapping(Class<?> inClazz, Map<Type, Type> resolvedTypes) { + Mappings.ClassMapping original = super.createClassMapping(inClazz, resolvedTypes); + if (!polymorphismHandler.hasPolymorphism(inClazz) || original.polymorphicDeserializedTypeResolver != null || original.serializedPolymorphicProperties != null) { + return original; + } + + polymorphismHandler.validateJsonbPolymorphismAnnotations(original.clazz); + polymorphismHandler.populateTypeInfoCache(original.clazz); + return new ClassMapping( + original.clazz, original.factory, original.getters, original.setters, + original.adapter, original.reader, original.writer, original.anyGetter, + original.anySetter, original.anyField, original.mapAdder, + polymorphismHandler.getPolymorphismPropertiesToSerialize(original.clazz, original.getters.keySet()), + polymorphismHandler::getTypeToDeserialize); + } + + public static class ClassMapping extends Mappings.ClassMapping { + protected ClassMapping(final Class<?> clazz, final AccessMode.Factory factory, + final Map<String, Getter> getters, final Map<String, Setter> setters, + final Adapter<?, ?> adapter, + final ObjectConverter.Reader<?> reader, final ObjectConverter.Writer<?> writer, + final Getter anyGetter, final Method anySetter, final Field anyField, + final Method mapAdder, + final Map.Entry<String, String>[] serializedPolymorphicProperties, + final BiFunction<JsonObject, Class<?>, Class<?>> polymorphicDeserializedTypeResolver) { + super(clazz, factory, getters, setters, adapter, reader, writer, anyGetter, anySetter, anyField, mapAdder, + serializedPolymorphicProperties, polymorphicDeserializedTypeResolver); + } + } +} diff --git a/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/polymorphism/JsonbPolymorphismHandler.java b/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/polymorphism/JsonbPolymorphismHandler.java new file mode 100644 index 00000000..5710ed0b --- /dev/null +++ b/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/polymorphism/JsonbPolymorphismHandler.java @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.johnzon.jsonb.polymorphism; + +import org.apache.johnzon.mapper.access.Meta; + +import jakarta.json.JsonObject; +import jakarta.json.JsonString; +import jakarta.json.JsonValue; +import jakarta.json.bind.JsonbException; +import jakarta.json.bind.annotation.JsonbSubtype; +import jakarta.json.bind.annotation.JsonbTypeInfo; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class JsonbPolymorphismHandler { + private final Map<Class<?>, JsonbPolymorphismTypeInfo> typeInfoCache = new HashMap<>(); + + public boolean hasPolymorphism(Class<?> clazz) { + return clazz.isAnnotationPresent(JsonbTypeInfo.class) || getParentWithTypeInfo(clazz) != null; + } + + public Map.Entry<String, String>[] getPolymorphismPropertiesToSerialize(Class<?> clazz, Collection<String> otherProperties) { + List<Map.Entry<String, String>> result = new ArrayList<>(); + + Class<?> current = clazz; + while (current != null) { + // Only try to resolve types when there's a JsonbTypeInfo Annotation present on the current type, Meta.getAnnotation tries to + // walk up parents by itself until it finds the given Annotation and could incorrectly cause JsonbExceptions to be thrown + // (multiple JsonbTypeInfos with same key found even if thats not actually the case) + if (current.isAnnotationPresent(JsonbTypeInfo.class)) { + JsonbTypeInfo typeInfo = Meta.getAnnotation(current, JsonbTypeInfo.class); + if (otherProperties.contains(typeInfo.key())) { + throw new JsonbException("JsonbTypeInfo key '" + typeInfo.key() + "' collides with other properties in json"); + } + + String bestMatchingAlias = null; + for (JsonbSubtype subtype : typeInfo.value()) { + if (subtype.type().isAssignableFrom(clazz)) { + bestMatchingAlias = subtype.alias(); + + if (clazz == subtype.type()) { // Exact match found, no need to continue further + break; + } + } + } + + if (bestMatchingAlias != null) { + result.add(0, Map.entry(typeInfo.key(), bestMatchingAlias)); + } + } + + current = getParentWithTypeInfo(current); + } + + return result.toArray(Map.Entry[]::new); + } + + public Class<?> getTypeToDeserialize(JsonObject jsonObject, Class<?> clazz) { + if (!typeInfoCache.containsKey(clazz)) { + return clazz; + } + + JsonbPolymorphismTypeInfo typeInfo = typeInfoCache.get(clazz); + if (!jsonObject.containsKey(typeInfo.getTypeKey())) { + return clazz; + } + + JsonValue typeValue = jsonObject.get(typeInfo.getTypeKey()); + if (typeValue.getValueType() != JsonValue.ValueType.STRING) { + throw new JsonbException("Property '" + typeInfo.getTypeKey() + "' isn't a String, resolving JsonbSubtype is impossible"); + } + + String typeValueString = ((JsonString) typeValue).getString(); + if (!typeInfo.getAliases().containsKey(typeValueString)) { + throw new JsonbException("No JsonbSubtype found for alias '" + typeValueString + "' on " + clazz.getName()); + } + + return typeInfo.getAliases().get(typeValueString); + } + + public void populateTypeInfoCache(Class<?> clazz) { + if (typeInfoCache.containsKey(clazz) || !clazz.isAnnotationPresent(JsonbTypeInfo.class)) { + return; + } + + typeInfoCache.put(clazz, new JsonbPolymorphismTypeInfo(Meta.getAnnotation(clazz, JsonbTypeInfo.class))); + } + + /** + * Validates {@link JsonbTypeInfo} annotation on clazz and its parents (superclass/interfaces), + * see {@link JsonbPolymorphismHandler#validateSubtypeCompatibility(Class)}, {@link JsonbPolymorphismHandler#validateOnlyOneParentWithTypeInfo(Class)} + * and {@link JsonbPolymorphismHandler#validateNoTypeInfoKeyCollision(Class)} + * @param classToValidate Class to validate + * @throws JsonbException validation failed + */ + public void validateJsonbPolymorphismAnnotations(Class<?> classToValidate) { + validateSubtypeCompatibility(classToValidate); + validateOnlyOneParentWithTypeInfo(classToValidate); + validateNoTypeInfoKeyCollision(classToValidate); + } + + /** + * Validation fails if any clazz and {@link JsonbSubtype#type()} aren't compatible. + * + * @param classToValidate Class to validate + * @throws JsonbException validation failed + */ + protected void validateSubtypeCompatibility(Class<?> classToValidate) { + if (!classToValidate.isAnnotationPresent(JsonbTypeInfo.class)) { + return; + } + + JsonbTypeInfo typeInfo = Meta.getAnnotation(classToValidate, JsonbTypeInfo.class); + for (JsonbSubtype subtype : typeInfo.value()) { + if (!classToValidate.isAssignableFrom(subtype.type())) { + throw new JsonbException("JsonbSubtype '" + subtype.alias() + "'" + + " (" + subtype.type().getName() + ") is not a subclass of " + classToValidate); + } + } + } + + /** + * Validates that only one parent class (superclass + interfaces) has {@link JsonbTypeInfo} annotation + * + * @param classToValidate class to validate + * @throws JsonbException validation failed + */ + protected void validateOnlyOneParentWithTypeInfo(Class<?> classToValidate) { + boolean found = classToValidate.getSuperclass() != null && Meta.getAnnotation(classToValidate.getSuperclass(), JsonbTypeInfo.class) != null; + + for (Class<?> iface : classToValidate.getInterfaces()) { + if (iface != null && Meta.getAnnotation(iface, JsonbTypeInfo.class) != null) { + if (found) { + throw new JsonbException("More than one interface/superclass of " + classToValidate.getName() + + " has JsonbTypeInfo Annotation"); + } + + found = true; + } + } + } + + /** + * Validates that {@link JsonbTypeInfo#key()} is only defined once in type hierarchy. + * Assumes {@link JsonbPolymorphismHandler#validateOnlyOneParentWithTypeInfo(Class)} already passed. + * + * @param classToValidate class to validate + * @throws JsonbException validation failed + */ + protected void validateNoTypeInfoKeyCollision(Class<?> classToValidate) { + Map<String, Class<?>> keyToDefiningClass = new HashMap<>(); + + Class<?> current = classToValidate; + while (current != null) { + if (current.isAnnotationPresent(JsonbTypeInfo.class)) { + String key = Meta.getAnnotation(current, JsonbTypeInfo.class).key(); + + if (keyToDefiningClass.containsKey(key)) { + throw new JsonbException("JsonbTypeInfo key '" + key + "' found more than once in type hierarchy of " + classToValidate + + " (first defined in " + keyToDefiningClass.get(key).getName() + ", then defined again in " + current.getName() + ")"); + } + + keyToDefiningClass.put(key, current); + } + + current = getParentWithTypeInfo(current); + } + } + + protected Class<?> getParentWithTypeInfo(Class<?> clazz) { + if (clazz.getSuperclass() != null && Meta.getAnnotation(clazz.getSuperclass(), JsonbTypeInfo.class) != null) { + return clazz.getSuperclass(); + } + + for (Class<?> iface : clazz.getInterfaces()) { + if (Meta.getAnnotation(iface, JsonbTypeInfo.class) != null) { + return iface; + } + } + + return null; + } +} diff --git a/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/polymorphism/JsonbPolymorphismTypeInfo.java b/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/polymorphism/JsonbPolymorphismTypeInfo.java new file mode 100644 index 00000000..7fa7dd85 --- /dev/null +++ b/johnzon-jsonb/src/main/java/org/apache/johnzon/jsonb/polymorphism/JsonbPolymorphismTypeInfo.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.johnzon.jsonb.polymorphism; + +import jakarta.json.bind.annotation.JsonbSubtype; +import jakarta.json.bind.annotation.JsonbTypeInfo; +import java.util.HashMap; +import java.util.Map; + +public class JsonbPolymorphismTypeInfo { + private String typeKey; + private Map<String, Class<?>> aliases; + + protected JsonbPolymorphismTypeInfo(JsonbTypeInfo annotation) { + this.typeKey = annotation.key(); + + aliases = new HashMap<>(); + for (JsonbSubtype subtype : annotation.value()) { + aliases.put(subtype.alias(), subtype.type()); + } + } + + public String getTypeKey() { + return typeKey; + } + + public Map<String, Class<?>> getAliases() { + return aliases; + } +} diff --git a/johnzon-jsonb/src/test/java/org/apache/johnzon/jsonb/polymorphism/JsonbPolymorphismTest.java b/johnzon-jsonb/src/test/java/org/apache/johnzon/jsonb/polymorphism/JsonbPolymorphismTest.java new file mode 100644 index 00000000..e3ebaf76 --- /dev/null +++ b/johnzon-jsonb/src/test/java/org/apache/johnzon/jsonb/polymorphism/JsonbPolymorphismTest.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.johnzon.jsonb.polymorphism; + +import org.apache.johnzon.jsonb.test.JsonbRule; +import org.junit.Rule; +import org.junit.Test; + +import jakarta.json.bind.annotation.JsonbSubtype; +import jakarta.json.bind.annotation.JsonbTypeInfo; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class JsonbPolymorphismTest { + + @Rule public JsonbRule jsonb = new JsonbRule(); + + @Test + public void testSerialization() { + Labrador labrador = new Labrador(); + labrador.dogAge = 3; + labrador.labradorName = "john"; + + assertEquals("{\"@animal\":\"dog\",\"@dog\":\"labrador\",\"dogAge\":3,\"labradorName\":\"john\"}", + jsonb.toJson(labrador)); + } + + @Test + public void testDeserialization() { + Animal deserialized = jsonb.fromJson("{\"@animal\":\"dog\",\"@dog\":\"labrador\",\"dogAge\":3,\"labradorName\":\"john\"}", Animal.class); + assertTrue(deserialized instanceof Labrador); + assertEquals(3, ((Labrador) deserialized).dogAge); + assertEquals("john", ((Labrador) deserialized).labradorName); + } + + @Test + public void testSubtypeSelfSerialization() { + Dog dog = new Dog(); + dog.dogAge = 3; + + assertEquals("{\"@animal\":\"dog\",\"@dog\":\"other\",\"dogAge\":3}", + jsonb.toJson(dog)); + } + + @Test + public void testSubtypeSelfDeserialization() { + Animal deserialized = jsonb.fromJson("{\"@animal\":\"dog\",\"@dog\":\"other\",\"dogAge\":3}", Animal.class); + + assertTrue(deserialized instanceof Dog); + assertEquals(3, ((Dog) deserialized).dogAge); + } + + @Test + public void testNoTypeInformationInJson() { + Dog dog = jsonb.fromJson("{\"dogAge\":3}", Dog.class); + + assertEquals(3, dog.dogAge); + } + + @JsonbTypeInfo(key = "@animal", value = @JsonbSubtype(alias = "dog", type = Dog.class)) + public interface Animal { + } + + @JsonbTypeInfo(key = "@dog", value = { + @JsonbSubtype(alias = "other", type = Dog.class), + @JsonbSubtype(alias = "labrador", type = Labrador.class) + }) + public static class Dog implements Animal { + public int dogAge; + } + + public static class Labrador extends Dog { + public String labradorName; + } +} diff --git a/johnzon-jsonb/src/test/java/org/apache/johnzon/jsonb/polymorphism/JsonbPolymorphismValidationTest.java b/johnzon-jsonb/src/test/java/org/apache/johnzon/jsonb/polymorphism/JsonbPolymorphismValidationTest.java new file mode 100644 index 00000000..610bfac0 --- /dev/null +++ b/johnzon-jsonb/src/test/java/org/apache/johnzon/jsonb/polymorphism/JsonbPolymorphismValidationTest.java @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.johnzon.jsonb.polymorphism; + +import org.apache.johnzon.jsonb.test.JsonbRule; +import org.junit.Rule; +import org.junit.Test; + +import jakarta.json.bind.JsonbException; +import jakarta.json.bind.annotation.JsonbSubtype; +import jakarta.json.bind.annotation.JsonbTypeInfo; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class JsonbPolymorphismValidationTest { + + @Rule public JsonbRule jsonb = new JsonbRule(); + + @Test + public void testMultipleParentsSerialization() { + Dog dog = new Dog(); + + JsonbException exception = assertThrows(JsonbException.class, () -> jsonb.toJson(dog)); + assertEquals("More than one interface/superclass of " + + "org.apache.johnzon.jsonb.polymorphism.JsonbPolymorphismValidationTest$Dog" + + " has JsonbTypeInfo Annotation", exception.getMessage()); + } + + @Test + public void testMultipleParentsDeserialization() { + String json = "{\"@animal\": \"dog\", \"@pet\": \"dog\"}"; + + JsonbException exception = assertThrows(JsonbException.class, () -> jsonb.fromJson(json, Dog.class)); + assertEquals("More than one interface/superclass of " + + "org.apache.johnzon.jsonb.polymorphism.JsonbPolymorphismValidationTest$Dog" + + " has JsonbTypeInfo Annotation", exception.getMessage()); + } + + + @Test + public void testIncompatibleSubtypeSerialization() { + InvalidSubTypeOther invalidSubTypeOther = new InvalidSubTypeOther(); + + JsonbException exception = assertThrows(JsonbException.class, () -> jsonb.toJson(invalidSubTypeOther)); + assertEquals("JsonbSubtype 'invalid' (java.lang.String)" + " is not a subclass of class" + + " org.apache.johnzon.jsonb.polymorphism.JsonbPolymorphismValidationTest$InvalidSubTypeOther", + exception.getMessage()); + } + + @Test + public void testIncompatibleSubtypeDeserialization() { + String json = "{\"@type\": \"invalid\"}"; + JsonbException exception = assertThrows(JsonbException.class, () -> jsonb.fromJson(json, InvalidSubTypeOther.class)); + + assertEquals("JsonbSubtype 'invalid' (java.lang.String)" + " is not a subclass of class" + + " org.apache.johnzon.jsonb.polymorphism.JsonbPolymorphismValidationTest$InvalidSubTypeOther", + exception.getMessage()); + } + + @Test + public void testPropertyNameCollision() { + Excavator excavator = new Excavator(); + excavator.type = "other"; + + JsonbException exception = assertThrows(JsonbException.class, () -> jsonb.toJson(excavator)); + assertEquals("JsonbTypeInfo key 'type' collides with other properties in json", exception.getMessage()); + } + + @Test + public void testTypeInfoKeyCollision() { + JsonbException exception = assertThrows(JsonbException.class, () -> jsonb.toJson(new MyCar())); + + assertEquals("JsonbTypeInfo key '@type' found more than once in type hierarchy of class " + + "org.apache.johnzon.jsonb.polymorphism.JsonbPolymorphismValidationTest$MyCar" + + " (first defined in org.apache.johnzon.jsonb.polymorphism.JsonbPolymorphismValidationTest$Car," + + " then defined again in org.apache.johnzon.jsonb.polymorphism.JsonbPolymorphismValidationTest$Vehicle)", exception.getMessage()); + } + + @Test + public void testTypePropertyNotString() { + JsonbException exception = assertThrows(JsonbException.class, () ->jsonb.fromJson("{\"@animal\": 42}", Animal.class)); + assertEquals("Property '@animal' isn't a String, resolving JsonbSubtype is impossible", exception.getMessage()); + } + + @Test + public void testUnknownAlias() { + JsonbException exception = assertThrows(JsonbException.class, () ->jsonb.fromJson("{\"@animal\": \"cat\"}", Animal.class)); + assertEquals("No JsonbSubtype found for alias 'cat' on" + + " org.apache.johnzon.jsonb.polymorphism.JsonbPolymorphismValidationTest$Animal", exception.getMessage()); + } + + @JsonbTypeInfo(key = "@animal", value = @JsonbSubtype(alias = "dog", type = Dog.class)) + public interface Animal { + } + + @JsonbTypeInfo(key = "pet", value = @JsonbSubtype(alias = "dog", type = Dog.class)) + public interface Pet { + } + + public static final class Dog implements Animal, Pet { + } + + @JsonbTypeInfo(@JsonbSubtype(alias = "invalid", type = String.class)) + public static final class InvalidSubTypeOther { + } + + + @JsonbTypeInfo(key = "type", value = @JsonbSubtype(alias = "excavator", type = Excavator.class)) + public static class Machine { + public String type; + } + + public static class Excavator extends Machine { + } + + @JsonbTypeInfo(@JsonbSubtype(alias = "car", type = Car.class)) + public static class Vehicle { + } + + @JsonbTypeInfo(@JsonbSubtype(alias = "myCar", type = MyCar.class)) + public static class Car extends Vehicle { + } + + public static class MyCar extends Car { + } +} diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/Mapper.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/Mapper.java index dc4f0860..34eb512a 100644 --- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/Mapper.java +++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/Mapper.java @@ -74,7 +74,7 @@ public class Mapper implements Closeable { this.builderFactory = builderFactory; this.provider = provider; this.config = config; - this.mappings = new Mappings(config); + this.mappings = this.config.getMappingsFactory() != null ? this.config.getMappingsFactory().apply(config) : new Mappings(config); this.closeables = closeables; this.charset = config.getEncoding(); } diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MapperBuilder.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MapperBuilder.java index 07ad02b5..91e6cd51 100644 --- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MapperBuilder.java +++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MapperBuilder.java @@ -107,6 +107,7 @@ public class MapperBuilder { private boolean supportEnumContainerDeserialization = true; private Function<Class<?>, MapperConfig.CustomEnumConverter<?>> enumConverterFactory = type -> new EnumConverter(type); private boolean skipAccessModeWrapper; + private Function<MapperConfig, Mappings> mappingsFactory; // @experimental polymorphic api private Function<String, Class<?>> typeLoader; @@ -238,7 +239,7 @@ public class MapperBuilder { typeLoader, discriminatorMapper, discriminator, deserializationPredicate, serializationPredicate, enumConverterFactory, - JohnzonCores.snippetFactory(snippetMaxLength, generatorFactory)), + JohnzonCores.snippetFactory(snippetMaxLength, generatorFactory), mappingsFactory), closeables); } @@ -564,4 +565,9 @@ public class MapperBuilder { this.skipAccessModeWrapper = skipAccessModeWrapper; return this; } -} + + public MapperBuilder setMappingsFactory(Function<MapperConfig, Mappings> mappingsFactory) { + this.mappingsFactory = mappingsFactory; + return this; + } +} \ No newline at end of file diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MapperConfig.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MapperConfig.java index ae69bfba..dd0ab71d 100644 --- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MapperConfig.java +++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MapperConfig.java @@ -98,6 +98,8 @@ public /* DON'T MAKE IT HIDDEN */ class MapperConfig implements Cloneable { private final SnippetFactory snippet; + private final Function<MapperConfig, Mappings> mappingsFactory; + //CHECKSTYLE:OFF @Deprecated public MapperConfig(final LazyConverterMap adapters, @@ -129,7 +131,7 @@ public /* DON'T MAKE IT HIDDEN */ class MapperConfig implements Cloneable { attributeOrder, failOnUnknown, serializeValueFilter, useBigDecimalForFloats, deduplicateObjects, interfaceImplementationMapping, useJsRange, useBigDecimalForObjectNumbers, supportEnumMapDeserialization, typeLoader, discriminatorMapper, discriminator, deserializationPredicate, serializationPredicate, enumConverterFactory, - JohnzonCores.snippetFactory(50, Json.createGeneratorFactory(emptyMap()))); + JohnzonCores.snippetFactory(50, Json.createGeneratorFactory(emptyMap())), null); } //disable checkstyle for 10+ parameters @@ -157,7 +159,8 @@ public /* DON'T MAKE IT HIDDEN */ class MapperConfig implements Cloneable { final Predicate<Class<?>> deserializationPredicate, final Predicate<Class<?>> serializationPredicate, final Function<Class<?>, CustomEnumConverter<?>> enumConverterFactory, - final SnippetFactory snippet) { + final SnippetFactory snippet, + final Function<MapperConfig, Mappings> mappingsFactory) { //CHECKSTYLE:ON this.objectConverterWriters = objectConverterWriters; this.objectConverterReaders = objectConverterReaders; @@ -196,6 +199,8 @@ public /* DON'T MAKE IT HIDDEN */ class MapperConfig implements Cloneable { this.useBigDecimalForFloats = useBigDecimalForFloats; this.deduplicateObjects = deduplicateObjects; this.snippet = snippet; + + this.mappingsFactory = mappingsFactory; } public SnippetFactory getSnippet() { @@ -464,6 +469,10 @@ public /* DON'T MAKE IT HIDDEN */ class MapperConfig implements Cloneable { return supportEnumMapDeserialization; } + public Function<MapperConfig, Mappings> getMappingsFactory() { + return mappingsFactory; + } + public interface CustomEnumConverter<A> extends Converter<A>, Converter.TypeAccess { } } diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MappingGeneratorImpl.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MappingGeneratorImpl.java index 2d6ac184..cf44c5c7 100644 --- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MappingGeneratorImpl.java +++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MappingGeneratorImpl.java @@ -154,7 +154,7 @@ public class MappingGeneratorImpl implements MappingGenerator { return; } - final Mappings.ClassMapping classMapping = mappings.getClassMapping(objectClass); // don't create here! + Mappings.ClassMapping classMapping = mappings.getClassMapping(objectClass); // don't create here! if (classMapping != null) { if (classMapping.adapter != null) { final Object result = classMapping.adapter.from(object); @@ -179,23 +179,26 @@ public class MappingGeneratorImpl implements MappingGenerator { } } else { if (classMapping == null) { // will be created anyway now so force it and if it has an adapter respect it - final Mappings.ClassMapping mapping = mappings.findOrCreateClassMapping(objectClass); - if (mapping != null && mapping.adapter != null) { - final Object result = mapping.adapter.from(object); - doWriteObject(result, generator, writeBody, ignoredProperties, jsonPointer); - return; - } + classMapping = mappings.findOrCreateClassMapping(objectClass); + } + + if (classMapping.adapter != null) { + final Object result = classMapping.adapter.from(object); + doWriteObject(result, generator, writeBody, ignoredProperties, jsonPointer); + return; } + if (writeBody) { generator.writeStartObject(); } - final boolean writeEnd; - if (config.getSerializationPredicate() != null && config.getSerializationPredicate().test(objectClass)) { - generator.write(config.getDiscriminator(), config.getDiscriminatorMapper().apply(objectClass)); - writeEnd = doWriteObjectBody(object, ignoredProperties, jsonPointer, generator); - } else { - writeEnd = doWriteObjectBody(object, ignoredProperties, jsonPointer, generator); + + if (classMapping.serializedPolymorphicProperties != null) { + for (Map.Entry<String, String> polymorphicProperty : classMapping.serializedPolymorphicProperties) { + generator.write(polymorphicProperty.getKey(), polymorphicProperty.getValue()); + } } + + final boolean writeEnd = doWriteObjectBody(object, ignoredProperties, jsonPointer, generator); if (writeEnd && writeBody) { generator.writeEnd(); } diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MappingParserImpl.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MappingParserImpl.java index 0eb23548..de9c1d3e 100644 --- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MappingParserImpl.java +++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MappingParserImpl.java @@ -274,17 +274,13 @@ public class MappingParserImpl implements MappingParser { } } - if (config.getDeserializationPredicate() != null && Class.class.isInstance(inType)) { - final Class<?> clazz = Class.class.cast(inType); - if (config.getDeserializationPredicate().test(clazz) && object.containsKey(config.getDiscriminator())) { - final String discriminator = object.getString(config.getDiscriminator()); - final Class<?> nestedType = config.getTypeLoader().apply(discriminator); - if (nestedType != null && nestedType != inType) { - return buildObject(nestedType, object, applyObjectConverter, jsonPointer, skippedConverters); - } + final Mappings.ClassMapping classMapping = mappings.findOrCreateClassMapping(type); + if (classMapping != null && classMapping.polymorphicDeserializedTypeResolver != null && inType instanceof Class) { + Class<?> nestedType = classMapping.polymorphicDeserializedTypeResolver.apply(object, (Class<?>) inType); + if (nestedType != null && nestedType != inType) { + return buildObject(nestedType, object, applyObjectConverter, jsonPointer, skippedConverters); } } - final Mappings.ClassMapping classMapping = mappings.findOrCreateClassMapping(type); if (classMapping == null) { if (ParameterizedType.class.isInstance(type)) { diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/Mappings.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/Mappings.java index 7c1776e1..ac170a4b 100644 --- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/Mappings.java +++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/Mappings.java @@ -23,6 +23,7 @@ import static java.util.Collections.emptyMap; import static org.apache.johnzon.mapper.reflection.Converters.matches; import static org.apache.johnzon.mapper.reflection.Generics.resolve; +import jakarta.json.JsonObject; import java.lang.annotation.Annotation; import java.lang.reflect.Array; import java.lang.reflect.Field; @@ -52,6 +53,7 @@ import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.function.BiFunction; import org.apache.johnzon.mapper.access.AccessMode; import org.apache.johnzon.mapper.access.FieldAccessMode; @@ -76,6 +78,9 @@ public class Mappings { public final Field anyField; public final Method mapAdder; public final Class<?> mapAdderType; + public final Map.Entry<String, String>[] serializedPolymorphicProperties; + public final BiFunction<JsonObject, Class<?>, Class<?>> polymorphicDeserializedTypeResolver; + public boolean deduplicateObjects; protected ClassMapping(final Class<?> clazz, final AccessMode.Factory factory, @@ -84,6 +89,17 @@ public class Mappings { final ObjectConverter.Reader<?> reader, final ObjectConverter.Writer<?> writer, final Getter anyGetter, final Method anySetter, final Field anyField, final Method mapAdder) { + this(clazz, factory, getters, setters, adapter, reader, writer, anyGetter, anySetter, anyField, mapAdder, null, null); + } + + protected ClassMapping(final Class<?> clazz, final AccessMode.Factory factory, + final Map<String, Getter> getters, final Map<String, Setter> setters, + final Adapter<?, ?> adapter, + final ObjectConverter.Reader<?> reader, final ObjectConverter.Writer<?> writer, + final Getter anyGetter, final Method anySetter, final Field anyField, + final Method mapAdder, + final Map.Entry<String, String>[] serializedPolymorphicProperties, + final BiFunction<JsonObject, Class<?>, Class<?>> polymorphicDeserializedTypeResolver) { this.clazz = clazz; this.factory = factory; this.getters = getters; @@ -96,6 +112,8 @@ public class Mappings { this.anyField = anyField; this.mapAdder = mapAdder; this.mapAdderType = mapAdder == null ? null : mapAdder.getParameterTypes()[1]; + this.serializedPolymorphicProperties = serializedPolymorphicProperties; + this.polymorphicDeserializedTypeResolver = polymorphicDeserializedTypeResolver; this.deduplicateObjects = isDeduplicateObjects(); } @@ -519,7 +537,11 @@ public class Mappings { false,false, false, false, true, null, null, -1, null) : null), accessMode.findAnySetter(clazz), anyField, - Map.class.isAssignableFrom(clazz) ? accessMode.findMapAdder(clazz) : null); + Map.class.isAssignableFrom(clazz) ? accessMode.findMapAdder(clazz) : null, + config.getSerializationPredicate() != null && config.getSerializationPredicate().test(clazz) + ? new Map.Entry[] { Map.entry(config.getDiscriminator(), config.getDiscriminatorMapper().apply(clazz)) } : null, + config.getDeserializationPredicate() != null && config.getDeserializationPredicate().test(clazz) + ? (jsonObject, type) -> config.getTypeLoader().apply(jsonObject.getString(config.getDiscriminator())) : null); accessMode.afterParsed(clazz); diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/Meta.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/Meta.java index adcf6504..bf0de8b6 100644 --- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/Meta.java +++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/Meta.java @@ -141,4 +141,4 @@ public final class Meta { } }); } -} +} \ No newline at end of file diff --git a/johnzon-mapper/src/test/java/org/superbiz/ExtendMappingTest.java b/johnzon-mapper/src/test/java/org/superbiz/ExtendMappingTest.java index febe682d..2a8be6c0 100644 --- a/johnzon-mapper/src/test/java/org/superbiz/ExtendMappingTest.java +++ b/johnzon-mapper/src/test/java/org/superbiz/ExtendMappingTest.java @@ -62,8 +62,8 @@ public class ExtendMappingTest { -1, true, true, true, false, false, false, new FieldAccessMode(false, false), StandardCharsets.UTF_8, String::compareTo, false, null, false, false, - emptyMap(), true, false, true, - null, null, null, null, null, + emptyMap(), true, false, true, null, + null, null, null, null, type -> new EnumConverter(type))); } diff --git a/pom.xml b/pom.xml index 86e68aa2..4647826d 100644 --- a/pom.xml +++ b/pom.xml @@ -346,10 +346,10 @@ <property name="ignorePattern" value="@version|@see" /> </module> <module name="MethodLength"> - <property name="max" value="250" /> + <property name="max" value="255" /> </module> <module name="ParameterNumber"> - <property name="max" value="11" /> + <property name="max" value="13" /> </module> <module name="EmptyBlock"> <property name="option" value="text" /> diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index edf4d318..324684d8 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -512,7 +512,9 @@ This module provides some extension to JSON-B. #### Polymorphism -This extension provides a way to handle polymorphism: +This extension shouldn't be used anymore if you don't absolutely rely on the JSON format it generates/parses. +Use JSON-B 3 polymorphism instead. It provides a way to handle polymorphism: + For the deserialization side you have to list the potential children on the root class: