diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractArray.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractArray.java index 0dad091..2049683 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractArray.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractArray.java @@ -121,7 +121,7 @@ default E first( Predicate test ) { * @throws IllegalArgumentException in case the given schema is not an interface * @since 1.0 */ - default void validateEach(Class> schema, Rule... rules) { + default void validateEach(Class schema, Rule... rules) { forEach( e -> JsonValidator.validate( e, schema, rules ) ); } } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractCollection.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractCollection.java index d3cf9c6..58e4a85 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractCollection.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractCollection.java @@ -29,6 +29,10 @@ import org.hisp.dhis.jsontree.internal.Surly; +import java.lang.reflect.Method; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; + import static org.hisp.dhis.jsontree.Validation.NodeType.ARRAY; import static org.hisp.dhis.jsontree.Validation.NodeType.OBJECT; @@ -176,6 +180,11 @@ public final V as( Class as ) { return viewed.as( as ); } + @Override + public V as( Class as, BiConsumer onCall ) { + return viewed.as( as, onCall ); + } + @Override public final String toString() { return viewed.toString(); diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index 29e4cc5..48d1a23 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -168,7 +168,7 @@ default void forEachValue( Consumer action ) { * @throws IllegalArgumentException in case the given schema is not an interface * @since 0.11 */ - default void validate( Class> schema, Rule... rules ) { + default void validate( Class schema, Rule... rules ) { JsonValidator.validate( this, schema, rules ); } } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonMixed.java b/src/main/java/org/hisp/dhis/jsontree/JsonMixed.java index f4c46ee..f6fcaa5 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonMixed.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonMixed.java @@ -1,5 +1,7 @@ package org.hisp.dhis.jsontree; +import org.hisp.dhis.jsontree.internal.Surly; + import java.io.Reader; import java.nio.file.Path; @@ -57,7 +59,7 @@ static JsonMixed of( String json ) { * {@code null} default mapping is used * @return root of the virtual tree representing the given JSON input */ - static JsonMixed of( String json, JsonTypedAccessStore store ) { + static JsonMixed of( String json, @Surly JsonTypedAccessStore store ) { return new JsonVirtualTree( json, store ); } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java index 70b0886..1bc0cfc 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java @@ -30,7 +30,10 @@ import org.hisp.dhis.jsontree.Validation.Rule; import org.hisp.dhis.jsontree.validation.JsonValidator; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.AnnotatedType; import java.util.Collection; +import java.util.List; import java.util.function.Function; import static java.util.Arrays.stream; @@ -48,6 +51,32 @@ @Validation.Ignore public interface JsonObject extends JsonAbstractObject { + /** + * An object property based on a default method declared in a type extending {@link JsonObject}. + * + * @param in the {@link JsonObject} type that declared the property + * @param jsonName of the property + * @param jsonType the type the property is resolved to internally when calling {@link #get(String, Class)} + * @param javaName the name of the java property accessed that caused the JSON property to be resolved + * @param javaType the return type of the underlying method that declares the property + * @param source the underlying method that declared the property + * @since 1.4 + */ + record Property(Class in, String jsonName, Class jsonType, + String javaName, AnnotatedType javaType, AnnotatedElement source) {} + + /** + * Note that there can be more than one property with the same {@link Property#javaName()} in case the method it + * reflects accesses more than one member from the JSON object. In such a case each access is a property of the + * accessed {@link Property#jsonName()} with the same {@link Property#javaName()}. + * + * @return a model of this object in form its properties in no particular order + * @since 1.4 + */ + static List properties(Class of) { + return JsonVirtualTree.properties( of ); + } + /** * Access to object fields by name. *

diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index 86090f9..6c80d42 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -38,6 +38,7 @@ import java.util.Map.Entry; import java.util.NoSuchElementException; import java.util.Optional; +import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.IntConsumer; diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java index ef3826c..7d542eb 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java @@ -31,10 +31,14 @@ import org.hisp.dhis.jsontree.internal.Surly; import java.io.Reader; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; +import java.lang.reflect.Type; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.BiConsumer; import java.util.function.BiPredicate; import java.util.function.Function; import java.util.function.Predicate; @@ -110,7 +114,7 @@ static JsonValue of( String json ) { * {@code null} default mapping is used * @return virtual JSON tree root {@link JsonValue} */ - static JsonValue of( String json, JsonTypedAccessStore store ) { + static JsonValue of( String json, @Surly JsonTypedAccessStore store ) { return json == null || "null".equals( json ) ? JsonVirtualTree.NULL : JsonMixed.of( json, store ); } @@ -241,6 +245,21 @@ default boolean isBoolean() { */ T as( Class as ); + /** + * Same as {@link #as(Class)} but with an additional parameter to pass a callback function. This allows to observe + * the API calls for meta-programming. This should not be used in "normal" API usage. + *

+ * Not all methods can be observed as some are handled internally without ever going via the proxy. However, in + * contrast to {@link #as(Class)} when using this method any call of a default method is handled via proxy. + * + * @param as assumed value type for this value + * @param onCall a function that is called before the proxy handles an API call that allows to observe calls + * @param value type returned + * @return this object as the provided type, this might mean this object is wrapped as the provided type or + * @since 1.4 + */ + T as( Class as, BiConsumer onCall ); + /** * @return This value as {@link JsonObject} (same as {@code as(JsonObject.class)}) */ diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java index 0479bd5..970e4db 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java @@ -41,9 +41,13 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; import java.util.function.Function; import java.util.stream.Stream; @@ -71,27 +75,36 @@ */ final class JsonVirtualTree implements JsonMixed, Serializable { - public static final JsonMixed NULL = new JsonVirtualTree( JsonNode.NULL, "$", JsonTypedAccess.GLOBAL, null ); + public static final JsonMixed NULL = new JsonVirtualTree( JsonNode.NULL, "$", new Access( JsonTypedAccess.GLOBAL, null) ); + + private static final Map, List> PROPERTIES = new ConcurrentHashMap<>(); + + public static List properties(Class of) { + return PROPERTIES.computeIfAbsent( of, JsonVirtualTree::captureProperties ); + } + + /** + * The access support is shared by all values that are derived from the same initial virtual tree. + * Therefore, it makes sense to group them in a single object to reduce the fields needed for each node. + */ + private record Access(@Surly JsonTypedAccessStore store, @Maybe ConcurrentMap cache) {} private final @Surly JsonNode root; private final @Surly String path; - private final transient @Surly JsonTypedAccessStore store; - private final transient @Maybe ConcurrentMap accessCache; + private final transient @Surly Access access; public JsonVirtualTree( @Maybe String json, @Surly JsonTypedAccessStore store ) { - this( json == null || json.isEmpty() ? JsonNode.EMPTY_OBJECT : JsonNode.of( json ), "$", store, null ); + this( json == null || json.isEmpty() ? JsonNode.EMPTY_OBJECT : JsonNode.of( json ), "$", new Access( store, null )); } public JsonVirtualTree( @Surly JsonNode root, @Surly JsonTypedAccessStore store ) { - this( root, "$", store, null ); + this( root, "$", new Access( store, null) ); } - private JsonVirtualTree( @Surly JsonNode root, @Surly String path, @Surly JsonTypedAccessStore store, - @Maybe ConcurrentMap accessCache ) { + private JsonVirtualTree( @Surly JsonNode root, @Surly String path, @Surly Access access ) { this.root = root; this.path = path; - this.store = store; - this.accessCache = accessCache; + this.access = access; } @Surly @Override @@ -101,17 +114,19 @@ public String path() { @Surly @Override public JsonTypedAccessStore getAccessStore() { - return store; + return access.store; } @Override public boolean isAccessCached() { - return accessCache != null; + return access.cache != null; } @Override public JsonVirtualTree withAccessCached() { - return isAccessCached() ? this : new JsonVirtualTree( root, path, store, new ConcurrentHashMap<>() ); + return isAccessCached() + ? this + : new JsonVirtualTree( root, path, new Access( access.store, new ConcurrentHashMap<>() ) ); } @Override @@ -143,15 +158,15 @@ private JsonNode value() { @Override public T get( int index, Class as ) { - return asType( as, new JsonVirtualTree( root, path + "[" + index + "]", store, accessCache ) ); + return asType( as, new JsonVirtualTree( root, path + "[" + index + "]", access ) ); } @Override public T get( String name, Class as ) { if ( name.isEmpty() ) return as( as ); boolean isQualified = name.startsWith( "{" ) || name.startsWith( "." ) || name.startsWith( "[" ); - String p = isQualified ? path + name : path + "." + name; - return asType( as, new JsonVirtualTree( root, p, store, accessCache ) ); + String canonicalPath = isQualified ? path + name : path + "." + name; + return asType( as, new JsonVirtualTree( root, canonicalPath, access) ); } @Override @@ -159,9 +174,20 @@ public T as( Class as ) { return asType( as, this ); } + @Override + @SuppressWarnings( "unchecked" ) + public T as( Class as, BiConsumer onCall ) { + return (T) Proxy.newProxyInstance( + Thread.currentThread().getContextClassLoader(), new Class[] { as }, + ( proxy, method, args ) -> { + onCall.accept( method, args ); + return onInvoke( proxy, as, this, method, args, true ); + } ); + } + @SuppressWarnings( "unchecked" ) - private T asType( Class as, JsonVirtualTree res ) { - return isExtended( as ) ? createProxy( as, res ) : (T) res; + private T asType( Class as, JsonVirtualTree target ) { + return isJsonMixedSubType( as ) ? createProxy( as, target ) : (T) target; } @Override @@ -264,27 +290,40 @@ public String toString() { } @SuppressWarnings( "unchecked" ) - private E createProxy( Class as, JsonVirtualTree inner ) { + private E createProxy( Class as, JsonVirtualTree target ) { return (E) Proxy.newProxyInstance( Thread.currentThread().getContextClassLoader(), new Class[] { as }, - ( proxy, method, args ) -> { - // are we dealing with a default method in the extending class? - Class declaringClass = method.getDeclaringClass(); - if ( declaringClass == JsonValue.class && "asType".equals( method.getName() ) - && method.getParameterCount() == 0 ) { - return as; - } - if ( isExtended( declaringClass ) ) { - if ( method.isDefault() ) { - // call the default method of the proxied type itself - return callDefaultMethod( proxy, method, declaringClass, args ); - } - // abstract extending interface method? - return callAbstractMethod( inner, method, args ); - } - // call the same method on the wrapped object (assuming it has it) - return callCoreApiMethod( inner, method, args ); - } ); + ( proxy, method, args ) -> onInvoke( proxy, as, target, method, args, false ) ); + } + + /** + * @param proxy instance of the proxy the method was invoked upon + * @param as the type the proxy represents + * @param target the underlying tree that in the end holds the node against which a call potentially is resolved + * @param method the method of the proxy that was called + * @param args the arguments for the method + * @return the result of the method call + * @param type of the proxy + */ + private Object onInvoke( Object proxy, Class as, JsonVirtualTree target, Method method, + Object[] args, boolean alwaysCallDefault ) + throws Throwable { + // are we dealing with a default method in the extending class? + Class declaringClass = method.getDeclaringClass(); + if ( declaringClass == JsonValue.class && "asType".equals( method.getName() ) + && method.getParameterCount() == 0 ) { + return as; + } + if ( isJsonMixedSubType( declaringClass ) || method.isDefault() && alwaysCallDefault ) { + if ( method.isDefault() ) { + // call the default method of the proxied type itself + return callDefaultMethod( proxy, method, declaringClass, args ); + } + // abstract extending interface method? + return callAbstractMethod( target, method, args ); + } + // call the same method on the wrapped object (assuming it has it) + return callCoreApiMethod( target, method, args ); } /** @@ -304,28 +343,26 @@ private static Object callDefaultMethod( Object proxy, Method method, Class d * Abstract interface methods are "implemented" by deriving an {@link JsonGenericTypedAccessor} from the method's * return type and have the accessor extract the value by using the underlying {@link JsonValue} API. */ - private Object callAbstractMethod( JsonVirtualTree inner, Method method, Object[] args ) { - JsonObject obj = inner.asObject(); + private Object callAbstractMethod( JsonVirtualTree target, Method method, Object[] args ) { + JsonObject obj = target.asObject(); Class resType = method.getReturnType(); String name = stripGetterPrefix( method ); boolean hasDefault = method.getParameterCount() == 1 && method.getParameterTypes()[0] == resType; if ( obj.get( name ).isUndefined() && hasDefault ) { return args[0]; } - if ( accessCache != null && isCacheable( resType ) ) { - String keyId = inner.path + "." + name + ":" + toSignature( method.getGenericReturnType() ); - return accessCache.computeIfAbsent( keyId, key -> access( method, obj, name ) ); + if ( access.cache != null && isCacheable( resType ) ) { + String keyId = target.path + "." + name + ":" + toSignature( method.getGenericReturnType() ); + return access.cache.computeIfAbsent( keyId, key -> access( method, obj, name ) ); } return access( method, obj, name ); } private Object access( Method method, JsonObject obj, String name ) { Type genericType = method.getGenericReturnType(); - JsonTypedAccessStore accessStore = store == null ? JsonTypedAccess.GLOBAL : store; - JsonGenericTypedAccessor accessor = accessStore.accessor( method.getReturnType() ); - if ( accessor != null ) { - return accessor.access( obj, name, genericType, accessStore ); - } + JsonGenericTypedAccessor accessor = access.store.accessor( method.getReturnType() ); + if ( accessor != null ) + return accessor.access( obj, name, genericType, access.store ); throw new UnsupportedOperationException( "No accessor registered for type: " + genericType ); } @@ -333,17 +370,17 @@ private Object access( Method method, JsonObject obj, String name ) { * All methods by the core API of the general JSON tree represented as {@link JsonValue}s (and the general * subclasses) are implemented by the {@link JsonVirtualTree} wrapper, so they can be called directly. */ - private static Object callCoreApiMethod( JsonVirtualTree inner, Method method, Object[] args ) + private static Object callCoreApiMethod( JsonVirtualTree target, Method method, Object[] args ) throws Throwable { return args == null || args.length == 0 - ? MethodHandles.lookup().unreflect( method ).invokeWithArguments( inner ) - : MethodHandles.lookup().unreflect( method ).bindTo( inner ).invokeWithArguments( args ); + ? MethodHandles.lookup().unreflect( method ).invokeWithArguments( target ) + : MethodHandles.lookup().unreflect( method ).bindTo( target ).invokeWithArguments( args ); } /** * This is twofold: Concepts like {@link Stream} and {@link Iterator} should not be cached to work correctly. *

- * For all other type this is about reaching a balance between memory usage and CPU usage. Simple objects are + * For all other types this is about reaching a balance between memory usage and CPU usage. Simple objects are * recomputed whereas complex objects are not. */ private boolean isCacheable( Class resType ) { @@ -353,7 +390,11 @@ private boolean isCacheable( Class resType ) { && !JsonPrimitive.class.isAssignableFrom( resType ); } - private static boolean isExtended( Class declaringClass ) { + /** + * Logically we check for JsonMixed but to be safe any method implemented by {@linkplain JsonVirtualTree} should be + * considered as "core" and be handled directly + */ + private static boolean isJsonMixedSubType( Class declaringClass ) { return !declaringClass.isAssignableFrom( JsonVirtualTree.class ); } @@ -396,4 +437,52 @@ private void toSignature( Type type, StringBuilder str ) { str.append( '?' ); } } + + private static List captureProperties(Class of) { + List res = new ArrayList<>(); + propertyMethods(of).forEach( m -> { + @SuppressWarnings( "unchecked" ) + Class in = (Class) m.getDeclaringClass(); + JsonObject obj = JsonMixed.of( "{}" ).as( of, (method, args) -> { + if ( isJsonObjectGetAs( method ) ) { + String name = (String) args[0]; + @SuppressWarnings( "unchecked" ) + Class type = (Class) args[1]; + res.add( new Property( in, name, type, m.getName(), m.getAnnotatedReturnType(), m ) ); + } + }); + invokePropertyMethod( obj, m ); // may add zero, one or more properties via the callback + } ); + return List.copyOf( res ); + } + + private static boolean isJsonObjectGetAs( Method method ) { + return "get".equals( method.getName() ) + && method.getParameterCount() == 2 + && method.getDeclaringClass() == JsonObject.class; + } + + private static void invokePropertyMethod(JsonObject obj, Method property) { + try { + MethodHandles.lookup().unreflect( property ).invokeWithArguments( obj ); + } catch ( Throwable e ) { + // ignore + } + } + + private static Stream propertyMethods( Class of ) { + return Stream.of( of.getMethods() ) + .filter( JsonVirtualTree::isJsonObjectSubTypeProperty ); + } + + /** + * @return Only true for methods declared in interfaces extending {@link JsonObject}. All core API methods are + * excluded. + */ + private static boolean isJsonObjectSubTypeProperty(Method m) { + return m.isDefault() + && m.getParameterCount() == 0 + && isJsonMixedSubType( m.getDeclaringClass() ) + && m.getReturnType() != void.class; + } } diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java b/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java index 4dd6bda..7428234 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java @@ -3,6 +3,7 @@ import org.hisp.dhis.jsontree.JsonAbstractObject; import org.hisp.dhis.jsontree.JsonArray; import org.hisp.dhis.jsontree.JsonMixed; +import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.JsonPathException; import org.hisp.dhis.jsontree.JsonSchemaException; import org.hisp.dhis.jsontree.JsonSchemaException.Info; @@ -22,15 +23,15 @@ */ public final class JsonValidator { - public static void validate( JsonValue value, Class> schema ) { + public static void validate( JsonValue value, Class schema ) { validate( value, schema, Set.of() ); } - public static void validate( JsonValue value, Class> schema, Validation.Rule... rules ) { + public static void validate( JsonValue value, Class schema, Validation.Rule... rules ) { validate( value, schema, Set.of( rules ) ); } - public static void validate( JsonValue value, Class> schema, Set rules ) { + public static void validate( JsonValue value, Class schema, Set rules ) { if (!schema.isInterface()) throw new IllegalArgumentException("Must be an interface bust was: "+schema); if ( !value.exists() ) diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java index b17fd9b..9ee94a4 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -1,8 +1,6 @@ package org.hisp.dhis.jsontree.validation; -import org.hisp.dhis.jsontree.JsonMixed; -import org.hisp.dhis.jsontree.JsonNode; -import org.hisp.dhis.jsontree.JsonPathException; +import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.JsonValue; import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.NodeType; @@ -15,11 +13,9 @@ import org.hisp.dhis.jsontree.validation.PropertyValidation.ValueValidation; import java.lang.annotation.Annotation; -import java.lang.invoke.MethodHandles; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.AnnotatedParameterizedType; import java.lang.reflect.AnnotatedType; -import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.math.BigInteger; @@ -36,7 +32,6 @@ import java.util.stream.Stream; import static java.lang.Double.isNaN; -import static java.lang.reflect.Modifier.isAbstract; import static java.util.Comparator.comparing; import static org.hisp.dhis.jsontree.Validation.NodeType.ARRAY; import static org.hisp.dhis.jsontree.Validation.NodeType.BOOLEAN; @@ -59,7 +54,7 @@ record ObjectValidation( @Surly Map types, @Surly Map properties) { - private static final Map, ObjectValidation> INSTANCES = new ConcurrentHashMap<>(); + private static final Map, ObjectValidation> INSTANCES = new ConcurrentHashMap<>(); private static final Map, PropertyValidation> RECORD_BY_JAVA_TYPE = new ConcurrentSkipListMap<>( comparing( Class::getName ) ); @@ -78,52 +73,30 @@ record ObjectValidation( * string property) */ @Surly - public static ObjectValidation getInstance( Class schema ) { + public static ObjectValidation getInstance( Class schema ) { if ( !schema.isInterface() ) throw new IllegalArgumentException( "Schema must be an interface" ); return INSTANCES.computeIfAbsent( schema, ObjectValidation::createInstance ); } - private static ObjectValidation createInstance( Class schema ) { + private static ObjectValidation createInstance( Class schema ) { Map properties = new HashMap<>(); Map types = new HashMap<>(); - propertyMethods( schema ) - .forEach( m -> { - String property = captureProperty( m, schema ); - if ( property != null ) { - properties.put( property, fromMethod( m ) ); - types.put( property, m.getGenericReturnType() ); - } - } ); + JsonObject.properties( schema ).stream().filter( ObjectValidation::isNotIgnored ).forEach( p -> { + properties.put(p.jsonName(), fromProperty( p )); + types.put( p.jsonName(), p.javaType().getType() ); + } ); return new ObjectValidation( schema, Map.copyOf( types ), Map.copyOf( properties ) ); } - private static String captureProperty( Method m, Class schema ) { - String[] box = new String[1]; - T value = JsonMixed.of( JsonNode.of( "{}", path -> box[0] = path.substring( 1 ) ) ).as( schema ); - try { - Object res = MethodHandles.lookup().unreflect( m ).invokeWithArguments( value ); - // for virtual nodes force lookup by resolving the underlying node in the actual tree - if ( res instanceof JsonValue node ) node.node(); - return box[0]; - } catch ( JsonPathException e ) { - return box[0]; - } catch ( Throwable ex ) { - return null; - } - } - - private static Stream propertyMethods( Class schema ) { - return Stream.of( schema.getMethods() ) - .filter( m -> m.getDeclaringClass().isInterface() && !m.getDeclaringClass() - .isAnnotationPresent( Validation.Ignore.class ) ) - .filter( m -> m.getReturnType() != void.class && !m.isAnnotationPresent( Validation.Ignore.class ) ) - .filter( m -> m.getParameterCount() == 0 && (m.isDefault()) || isAbstract( m.getModifiers() ) ); + private static boolean isNotIgnored( JsonObject.Property p ) { + return !p.source().isAnnotationPresent( Validation.Ignore.class ) + && !p.in().isAnnotationPresent( Validation.Ignore.class ); } @Maybe - private static PropertyValidation fromMethod( Method src ) { - PropertyValidation onMethod = fromAnnotations( src ); - PropertyValidation onReturnType = fromValueTypeUse( src.getAnnotatedReturnType() ); + private static PropertyValidation fromProperty( JsonObject.Property p ) { + PropertyValidation onMethod = fromAnnotations( p.source() ); + PropertyValidation onReturnType = fromValueTypeUse( p.javaType() ); if ( onMethod == null ) return onReturnType; if ( onReturnType == null ) return onMethod; return onMethod.overlay( onReturnType ); diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java index 9944991..f0835dc 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -5,6 +5,7 @@ import org.hisp.dhis.jsontree.JsonMap; import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonNodeType; +import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.JsonValue; import org.hisp.dhis.jsontree.Validation.Error; import org.hisp.dhis.jsontree.Validation.NodeType; @@ -77,12 +78,12 @@ public void validate( JsonMixed value, Consumer addError ) { Comparator.comparing( Class::getName ) ); @Surly - public static ObjectValidator getInstance( Class schema ) { + public static ObjectValidator getInstance( Class schema ) { return getInstance( schema, new HashSet<>() ); } @Surly - private static ObjectValidator getInstance( Class schema, Set> currentlyResolved ) { + private static ObjectValidator getInstance( Class schema, Set> currentlyResolved ) { return getInstance( schema, () -> ObjectValidation.getInstance( schema ), currentlyResolved ); } @@ -123,9 +124,9 @@ private static ObjectValidator getInstance( Class schema, @Maybe private static Validator getInstance( java.lang.reflect.Type type, Set> currentlyResolved ) { if ( type instanceof Class cType ) { - if ( JsonAbstractObject.class.isAssignableFrom( cType ) ) { + if ( JsonObject.class.isAssignableFrom( cType ) ) { @SuppressWarnings( "unchecked" ) - Class schema = (Class) cType; + Class schema = (Class) cType; if ( currentlyResolved.contains( schema ) ) return new Lazy( schema, new AtomicReference<>() ); return getInstance( schema, currentlyResolved ); } @@ -658,7 +659,7 @@ private record DependentRequiredCodependent(Map of, AtomicReference instance) implements Validator { + private record Lazy(Class of, AtomicReference instance) implements Validator { @Override public void validate( JsonMixed value, Consumer addError ) { diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java index b6b81fc..ca3703a 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java @@ -34,9 +34,6 @@ record PropertyValidation( @Maybe ArrayValidation arrays, @Maybe ObjectValidation objects, @Maybe PropertyValidation items - //TODO maybe add a Map>> origin, - // which remembers where (annotation or validators) a validation originates from - // but this is difficult to keep accurate with the overlay ) { /** diff --git a/src/test/java/org/hisp/dhis/jsontree/Assertions.java b/src/test/java/org/hisp/dhis/jsontree/Assertions.java index 6089f4d..b0d9163 100644 --- a/src/test/java/org/hisp/dhis/jsontree/Assertions.java +++ b/src/test/java/org/hisp/dhis/jsontree/Assertions.java @@ -13,13 +13,13 @@ public class Assertions { public static Validation.Error assertValidationError( String actualJson, - Class> schema, + Class schema, Validation.Rule expected, Object... args ) { return assertValidationError( JsonMixed.of( actualJson ), schema, expected, args ); } - public static Validation.Error assertValidationError( JsonAbstractObject actual, - Class> schema, + public static Validation.Error assertValidationError( JsonObject actual, + Class schema, Validation.Rule expected, Object... args ) { JsonSchemaException ex = assertThrowsExactly( JsonSchemaException.class, () -> actual.validate( schema ), "expected an error of type " + expected ); diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java new file mode 100644 index 0000000..938444a --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java @@ -0,0 +1,89 @@ +package org.hisp.dhis.jsontree; + +import org.hisp.dhis.jsontree.JsonObject.Property; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedType; +import java.util.List; +import java.util.Set; + +import static java.util.stream.Collectors.toSet; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Tests the extraction of properties provided by {@link JsonObject#properties(Class)}. + *

+ * The coverage here is very shallow because the feature is used as part of the validation which has plenty of coverage + * for different types, annotations and so on. + * + * @author Jan Bernitt + */ +class JsonObjectPropertiesTest { + + private static final ClassType STRING = new ClassType( String.class ); + + public interface User extends JsonObject { + + default String username() { + return getString( "username" ).string(); + } + + default String name() { + return getString( "firstName" ).string() + " " + getString( "lastName" ).string(); + } + + } + + @Test + void testString() { + List properties = JsonObject.properties( User.class ); + Property expected = new Property( User.class, "username", JsonString.class, "username", + STRING, null ); + assertPropertyExists( "username", expected, properties ); + } + + @Test + void testString_Multiple() { + List properties = JsonObject.properties( User.class ); + assertEquals( 3, properties.size() ); + assertEquals( Set.of( "username", "firstName", "lastName" ), + properties.stream().map( Property::jsonName ).collect( toSet() ) ); + assertEquals( Set.of( "username", "name" ), properties.stream().map( Property::javaName ).collect( toSet() ) ); + + assertPropertyExists( "firstName", + new Property( User.class, "firstName", JsonString.class, "name", STRING, null ), + properties ); + assertPropertyExists( "lastName", + new Property( User.class, "lastName", JsonString.class, "name", STRING, null ), + properties ); + } + + private void assertPropertyExists( String jsonName, Property expected, List actual ) { + Property prop = actual.stream().filter( p -> p.jsonName().equals( jsonName ) ).findFirst() + .orElse( null ); + assertNotNull( prop ); + assertSame( expected.in(), prop.in() ); + assertEquals( expected.jsonName(), prop.jsonName() ); + assertSame( expected.jsonType(), prop.jsonType() ); + assertEquals( expected.javaName(), prop.javaName() ); + assertSame( expected.javaType().getType(), prop.javaType().getType() ); + } + + record ClassType(Class getType) implements AnnotatedType { + + @Override public T getAnnotation( Class aClass ) { + return null; + } + + @Override public Annotation[] getAnnotations() { + return new Annotation[0]; + } + + @Override public Annotation[] getDeclaredAnnotations() { + return new Annotation[0]; + } + } +}