Add a bean validator

Added a bean validator that will validate the fields within an arbitrary
object using the annotations in the parameters package.
Also added validateMap to the bean validators.

Issue-ID: POLICY-1625
Signed-off-by: Jim Hahn <jrh3@att.com>
Change-Id: I2192f3d1ba735d3779c35711a7dba053918aa547
diff --git a/common-parameters/src/main/java/org/onap/policy/common/parameters/BeanValidationResult.java b/common-parameters/src/main/java/org/onap/policy/common/parameters/BeanValidationResult.java
index f8eebcf..752e4d4 100644
--- a/common-parameters/src/main/java/org/onap/policy/common/parameters/BeanValidationResult.java
+++ b/common-parameters/src/main/java/org/onap/policy/common/parameters/BeanValidationResult.java
@@ -1,6 +1,6 @@
-/*
+/*-
  * ============LICENSE_START=======================================================
- *  Copyright (C) 2019 AT&T Intellectual Property. All rights reserved.
+ *  Copyright (C) 2019-2020 AT&T Intellectual Property. All rights reserved.
  * ================================================================================
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,6 +23,9 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.function.BiConsumer;
 import java.util.function.Function;
 
 /**
@@ -129,6 +132,34 @@
     }
 
     /**
+     * Validates the entries in a map.
+     *
+     * @param mapName name of the list
+     * @param map map whose entries are to be validated, or {@code null}
+     * @param entryValidator function to validate an entry in the map
+     * @return {@code true} if all entries in the map are valid, {@code false} otherwise
+     */
+    public <V> boolean validateMap(String mapName, Map<String, V> map,
+                    BiConsumer<BeanValidationResult, Entry<String, V>> entryValidator) {
+        if (map == null) {
+            return true;
+        }
+
+        BeanValidationResult result = new BeanValidationResult(mapName, null);
+        for (Entry<String, V> ent : map.entrySet()) {
+            entryValidator.accept(result, ent);
+        }
+
+        if (result.isValid()) {
+            return true;
+
+        } else {
+            addResult(result);
+            return false;
+        }
+    }
+
+    /**
      * Gets the validation result.
      *
      * @param initialIndentation the indentation to use on the main result output
diff --git a/common-parameters/src/main/java/org/onap/policy/common/parameters/BeanValidator.java b/common-parameters/src/main/java/org/onap/policy/common/parameters/BeanValidator.java
new file mode 100644
index 0000000..dbd3c7c
--- /dev/null
+++ b/common-parameters/src/main/java/org/onap/policy/common/parameters/BeanValidator.java
@@ -0,0 +1,353 @@
+/*-
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2020 AT&T Intellectual Property. All rights reserved.
+ * ================================================================================
+ * Licensed 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.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.common.parameters;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiPredicate;
+import java.util.function.Predicate;
+import org.apache.commons.lang3.StringUtils;
+import org.onap.policy.common.parameters.annotations.Max;
+import org.onap.policy.common.parameters.annotations.Min;
+import org.onap.policy.common.parameters.annotations.NotBlank;
+import org.onap.policy.common.parameters.annotations.NotNull;
+
+/**
+ * Bean validator, supporting the parameter annotations.
+ * <p/>
+ * Note: this currently does not support Min/Max validation of "short" or "byte"; these
+ * annotations are simply ignored for these types.
+ */
+public class BeanValidator {
+
+    /**
+     * {@code True} if there is a field-level annotation, {@code false} otherwise.
+     */
+    private boolean fieldIsAnnotated;
+
+    /**
+     * Validates top level fields within an object. For each annotated field, it retrieves
+     * the value using the public "getter" method for the field. If there is no public
+     * "getter" method, then it throws an exception. Otherwise, it validates the retrieved
+     * value based on the annotations. This recurses through super classes looking for
+     * fields to be verified, but it does not examine any interfaces.
+     *
+     * @param name name of the object being validated
+     * @param object object to be validated. If {@code null}, then an empty result is
+     *        returned
+     * @return the validation result
+     */
+    public BeanValidationResult validateTop(String name, Object object) {
+        BeanValidationResult result = new BeanValidationResult(name, object);
+        if (object == null) {
+            return result;
+        }
+
+        // check class hierarchy - don't need to check interfaces
+        for (Class<?> clazz = object.getClass(); clazz != null; clazz = clazz.getSuperclass()) {
+            validateFields(result, object, clazz);
+        }
+
+        return result;
+    }
+
+    /**
+     * Performs validation of all annotated fields found within the class.
+     *
+     * @param result validation results are added here
+     * @param object object whose fields are to be validated
+     * @param clazz class, within the object's hierarchy, to be examined for fields to be
+     *        verified
+     */
+    private void validateFields(BeanValidationResult result, Object object, Class<?> clazz) {
+        for (Field field : clazz.getDeclaredFields()) {
+            validateField(result, object, clazz, field);
+        }
+    }
+
+    /**
+     * Performs validation of a single field.
+     *
+     * @param result validation results are added here
+     * @param object object whose fields are to be validated
+     * @param clazz class, within the object's hierarchy, containing the field
+     * @param field field whose value is to be validated
+     */
+    private void validateField(BeanValidationResult result, Object object, Class<?> clazz, Field field) {
+        final String fieldName = field.getName();
+        if (fieldName.contains("$")) {
+            return;
+        }
+
+        /*
+         * Identify the annotations. NotNull MUST be first so the check is run before the
+         * others.
+         */
+        fieldIsAnnotated = false;
+        List<Predicate<Object>> checkers = new ArrayList<>(10);
+        addAnnotation(clazz, field, checkers, NotNull.class, (annot, value) -> verNotNull(result, fieldName, value));
+        addAnnotation(clazz, field, checkers, NotBlank.class, (annot, value) -> verNotBlank(result, fieldName, value));
+        addAnnotation(clazz, field, checkers, Max.class, (annot, value) -> verMax(result, fieldName, annot, value));
+        addAnnotation(clazz, field, checkers, Min.class, (annot, value) -> verMin(result, fieldName, annot, value));
+
+        if (checkers.isEmpty()) {
+            // has no annotations - nothing to check
+            return;
+        }
+
+        // verify the field type is of interest
+        int mod = field.getModifiers();
+        if (Modifier.isStatic(mod)) {
+            classOnly(clazz.getName() + "." + fieldName + " is annotated but the field is static");
+            return;
+        }
+
+        // get the field's "getter" method
+        Method accessor = getAccessor(object.getClass(), fieldName);
+        if (accessor == null) {
+            classOnly(clazz.getName() + "." + fieldName + " is annotated but has no \"get\" method");
+            return;
+        }
+
+        // get the value
+        Object value = getValue(object, clazz, fieldName, accessor);
+
+        // perform the checks
+        if (value == null && field.getAnnotation(NotNull.class) == null && clazz.getAnnotation(NotNull.class) == null) {
+            // value is null and there's no null check - just return
+            return;
+        }
+
+        for (Predicate<Object> checker : checkers) {
+            if (!checker.test(value)) {
+                // invalid - don't bother with additional checks
+                return;
+            }
+        }
+    }
+
+    /**
+     * Gets the value from the object using the accessor function.
+     *
+     * @param object object whose value is to be retrieved
+     * @param clazz class containing the field
+     * @param fieldName name of the field
+     * @param accessor "getter" method
+     * @return the object's value
+     */
+    private Object getValue(Object object, Class<?> clazz, final String fieldName, Method accessor) {
+        try {
+            return accessor.invoke(object);
+
+        } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
+            throw new IllegalArgumentException(clazz.getName() + "." + fieldName + " accessor threw an exception", e);
+        }
+    }
+
+    /**
+     * Throws an exception if there are field-level annotations.
+     *
+     * @param exceptionMessage exception message
+     */
+    private void classOnly(String exceptionMessage) {
+        if (fieldIsAnnotated) {
+            throw new IllegalArgumentException(exceptionMessage);
+        }
+    }
+
+    /**
+     * Looks for an annotation at the class or field level. If an annotation is found at
+     * either the field or class level, then it adds a verifier to the list of checkers.
+     *
+     * @param clazz class to be searched
+     * @param field field to be searched
+     * @param checkers where to place the new field verifier
+     * @param annotClass class of annotation to find
+     * @param check verification function to be added to the list, if the annotation is
+     *        found
+     */
+    private <T extends Annotation> void addAnnotation(Class<?> clazz, Field field, List<Predicate<Object>> checkers,
+                    Class<T> annotClass, BiPredicate<T, Object> check) {
+
+        // field annotation takes precedence over class annotation
+        T annot = field.getAnnotation(annotClass);
+        if (annot != null) {
+            fieldIsAnnotated = true;
+
+        } else if ((annot = clazz.getAnnotation(annotClass)) == null) {
+            return;
+        }
+
+        T annot2 = annot;
+        checkers.add(value -> check.test(annot2, value));
+    }
+
+    /**
+     * Verifies that the value is not null.
+     *
+     * @param result where to add the validation result
+     * @param fieldName field whose value is being verified
+     * @param value value to be verified, assumed to be non-null
+     * @return {@code true} if the value is valid, {@code false} otherwise
+     */
+    private boolean verNotNull(BeanValidationResult result, String fieldName, Object value) {
+        return result.validateNotNull(fieldName, value);
+    }
+
+    /**
+     * Verifies that the value is not blank.
+     *
+     * @param result where to add the validation result
+     * @param fieldName field whose value is being verified
+     * @param value value to be verified, assumed to be non-null
+     * @return {@code true} if the value is valid, {@code false} otherwise
+     */
+    private boolean verNotBlank(BeanValidationResult result, String fieldName, Object value) {
+        if (value instanceof String && StringUtils.isBlank(value.toString())) {
+            ObjectValidationResult fieldResult =
+                            new ObjectValidationResult(fieldName, value, ValidationStatus.INVALID, "is blank");
+            result.addResult(fieldResult);
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Verifies that the value is <= the minimum value.
+     *
+     * @param result where to add the validation result
+     * @param fieldName field whose value is being verified
+     * @param annot annotation against which the value is being verified
+     * @param value value to be verified, assumed to be non-null
+     * @return {@code true} if the value is valid, {@code false} otherwise
+     */
+    private boolean verMax(BeanValidationResult result, String fieldName, Max annot, Object value) {
+        if (!(value instanceof Number)) {
+            return true;
+        }
+
+        Number num = (Number) value;
+        if (num instanceof Integer || num instanceof Long) {
+            if (num.longValue() <= annot.value()) {
+                return true;
+            }
+
+        } else if (num instanceof Float || num instanceof Double) {
+            if (num.doubleValue() <= annot.value()) {
+                return true;
+            }
+
+        } else {
+            return true;
+        }
+
+        ObjectValidationResult fieldResult = new ObjectValidationResult(fieldName, value, ValidationStatus.INVALID,
+                        "exceeds the maximum value: " + annot.value());
+        result.addResult(fieldResult);
+        return false;
+    }
+
+    /**
+     * Verifies that the value is >= the minimum value.
+     *
+     * @param result where to add the validation result
+     * @param fieldName field whose value is being verified
+     * @param annot annotation against which the value is being verified
+     * @param value value to be verified, assumed to be non-null
+     * @return {@code true} if the value is valid, {@code false} otherwise
+     */
+    private boolean verMin(BeanValidationResult result, String fieldName, Min annot, Object value) {
+        if (!(value instanceof Number)) {
+            return true;
+        }
+
+        Number num = (Number) value;
+        if (num instanceof Integer || num instanceof Long) {
+            if (num.longValue() >= annot.value()) {
+                return true;
+            }
+
+        } else if (num instanceof Float || num instanceof Double) {
+            if (num.doubleValue() >= annot.value()) {
+                return true;
+            }
+
+        } else {
+            return true;
+        }
+
+        ObjectValidationResult fieldResult = new ObjectValidationResult(fieldName, value, ValidationStatus.INVALID,
+                        "is below the minimum value: " + annot.value());
+        result.addResult(fieldResult);
+        return false;
+    }
+
+    /**
+     * Gets an accessor method for the given field.
+     *
+     * @param clazz class whose methods are to be searched
+     * @param fieldName field whose "getter" is to be identified
+     * @return the field's "getter" method, or {@code null} if it is not found
+     */
+    private Method getAccessor(Class<?> clazz, String fieldName) {
+        String capname = StringUtils.capitalize(fieldName);
+        Method accessor = getMethod(clazz, "get" + capname);
+        if (accessor != null) {
+            return accessor;
+        }
+
+        return getMethod(clazz, "is" + capname);
+    }
+
+    /**
+     * Gets the "getter" method having the specified name.
+     *
+     * @param clazz class whose methods are to be searched
+     * @param methodName name of the method of interest
+     * @return the method, or {@code null} if it is not found
+     */
+    private Method getMethod(Class<?> clazz, String methodName) {
+        for (Method method : clazz.getMethods()) {
+            if (methodName.equals(method.getName()) && validMethod(method)) {
+                return method;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Determines if a method is a valid "getter".
+     *
+     * @param method method to be checked
+     * @return {@code true} if the method is a valid "getter", {@code false} otherwise
+     */
+    private boolean validMethod(Method method) {
+        int mod = method.getModifiers();
+        return !(Modifier.isStatic(mod) || method.getReturnType() == void.class || method.getParameterCount() != 0);
+    }
+}
diff --git a/common-parameters/src/test/java/org/onap/policy/common/parameters/TestBeanValidationResult.java b/common-parameters/src/test/java/org/onap/policy/common/parameters/TestBeanValidationResult.java
index 12cd80c..8f978c6 100644
--- a/common-parameters/src/test/java/org/onap/policy/common/parameters/TestBeanValidationResult.java
+++ b/common-parameters/src/test/java/org/onap/policy/common/parameters/TestBeanValidationResult.java
@@ -1,8 +1,8 @@
-/*
+/*-
  * ============LICENSE_START=======================================================
  * ONAP
  * ================================================================================
- * Copyright (C) 2019 AT&T Intellectual Property. All rights reserved.
+ * Copyright (C) 2019-2020 AT&T Intellectual Property. All rights reserved.
  * ================================================================================
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -27,17 +27,25 @@
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.TreeMap;
+import java.util.function.BiConsumer;
 import org.junit.Before;
 import org.junit.Test;
 
 public class TestBeanValidationResult {
+    private static final String TEXT1 = "abc";
+    private static final String TEXT2 = "def";
     private static final String MY_LIST = "my-list";
+    private static final String MY_MAP = "my-map";
     private static final String OBJECT = "an object";
     private static final String INITIAL_INDENT = "xx ";
     private static final String NEXT_INDENT = "yy ";
     private static final String MID_INDENT = "xx yy ";
     private static final String NAME = "my-name";
     private static final String MY_LIST_INVALID = "  'my-list' INVALID, item has status INVALID\n    ";
+    private static final String MY_MAP_INVALID = "  'my-map' INVALID, item has status INVALID\n    ";
     private static final String BEAN_INVALID_MSG = requote("'my-name' INVALID, item has status INVALID\n");
 
     private String cleanMsg;
@@ -52,10 +60,10 @@
      */
     @Before
     public void setUp() {
-        clean = new ObjectValidationResult("abc", 10);
+        clean = new ObjectValidationResult(TEXT1, 10);
         cleanMsg = clean.getResult("", "", true);
 
-        invalid = new ObjectValidationResult("def", 20);
+        invalid = new ObjectValidationResult(TEXT2, 20);
         invalid.setResult(ValidationStatus.INVALID, "invalid");
         invalidMsg = invalid.getResult();
 
@@ -150,6 +158,50 @@
 
     }
 
+    @Test
+    public void testValidateMap() {
+        Map<String,ValidationResult> map = null;
+        bean = new BeanValidationResult(NAME, OBJECT);
+        assertTrue(bean.validateMap(MY_MAP, map, validMapEntry()));
+        assertTrue(bean.isValid());
+        assertNull(bean.getResult());
+
+        map = Map.of(TEXT1, clean, TEXT2, clean);
+        bean = new BeanValidationResult(NAME, OBJECT);
+        assertTrue(bean.validateMap(MY_MAP, map, validMapEntry()));
+        assertTrue(bean.isValid());
+        assertNull(bean.getResult());
+
+        // null value in the map
+        map = new TreeMap<>();
+        map.put(TEXT1, clean);
+        map.put(TEXT2, null);
+        bean = new BeanValidationResult(NAME, OBJECT);
+        assertFalse(bean.validateMap(MY_MAP, map, validMapEntry()));
+        assertFalse(bean.isValid());
+        assertEquals(requote(BEAN_INVALID_MSG + MY_MAP_INVALID
+                        + "item 'def' value 'null' INVALID, is null\n"), bean.getResult());
+
+        map = Map.of(TEXT1, invalid, TEXT2, invalid);
+        bean = new BeanValidationResult(NAME, OBJECT);
+        assertFalse(bean.validateMap(MY_MAP, map, validMapEntry()));
+        assertFalse(bean.isValid());
+        assertEquals(requote(BEAN_INVALID_MSG + MY_MAP_INVALID + invalidMsg
+                        + "    " + invalidMsg), bean.getResult());
+
+    }
+
+    private BiConsumer<BeanValidationResult, Entry<String, ValidationResult>> validMapEntry() {
+        return (result, entry) -> {
+            var value = entry.getValue();
+            if (value == null) {
+                result.validateNotNull(entry.getKey(), value);
+            } else {
+                result.addResult(value);
+            }
+        };
+    }
+
     private static String requote(String text) {
         return text.replace('\'', '"');
     }
diff --git a/common-parameters/src/test/java/org/onap/policy/common/parameters/TestBeanValidator.java b/common-parameters/src/test/java/org/onap/policy/common/parameters/TestBeanValidator.java
new file mode 100644
index 0000000..f1e468b
--- /dev/null
+++ b/common-parameters/src/test/java/org/onap/policy/common/parameters/TestBeanValidator.java
@@ -0,0 +1,651 @@
+/*-
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2020 AT&T Intellectual Property. All rights reserved.
+ * ================================================================================
+ * Licensed 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.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.common.parameters;
+
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import lombok.AccessLevel;
+import lombok.Getter;
+import org.junit.Before;
+import org.junit.Test;
+import org.onap.policy.common.parameters.annotations.Max;
+import org.onap.policy.common.parameters.annotations.Min;
+import org.onap.policy.common.parameters.annotations.NotBlank;
+import org.onap.policy.common.parameters.annotations.NotNull;
+
+public class TestBeanValidator {
+    private static final String GET_MSG = "\"get\"";
+    private static final IllegalStateException EXPECTED_EXCEPTION = new IllegalStateException("expected exception");
+    private static final String TOP = "top";
+    private static final String STR_FIELD = "strValue";
+    private static final String INT_FIELD = "intValue";
+    private static final String NUM_FIELD = "numValue";
+    private static final String BOOL_FIELD = "boolValue";
+    private static final String STRING_VALUE = "string value";
+    private static final int INT_VALUE = 20;
+
+    private BeanValidator validator;
+
+    @Before
+    public void setUp() {
+        validator = new BeanValidator();
+    }
+
+    @Test
+    public void testValidateTop_testValidateFields() {
+        // validate null
+        assertTrue(validator.validateTop(TOP, null).isValid());
+
+        // validate something that has no annotations
+        assertTrue(validator.validateTop(TOP, validator).isValid());
+
+        @NotNull
+        @Getter
+        class Data {
+            String strValue;
+        }
+
+        // one failure case
+        Data data = new Data();
+        BeanValidationResult result = validator.validateTop(TOP, data);
+        assertInvalid("testValidateFields", result, STR_FIELD, "null");
+        assertTrue(result.getResult().contains(TOP));
+
+        // one success case
+        data.strValue = STRING_VALUE;
+        assertTrue(validator.validateTop(TOP, data).isValid());
+
+        /**
+         * Repeat with a subclass.
+         */
+        @Getter
+        class Derived extends Data {
+            @Min(10)
+            int intValue;
+        }
+
+        Derived derived = new Derived();
+        derived.strValue = STRING_VALUE;
+        derived.intValue = INT_VALUE;
+
+        // success case
+        assertTrue(validator.validateTop(TOP, derived).isValid());
+
+        // failure cases
+        derived.strValue = null;
+        assertInvalid("testValidateFields", validator.validateTop(TOP, derived), STR_FIELD, "null");
+        derived.strValue = STRING_VALUE;
+
+        derived.intValue = 1;
+        assertInvalid("testValidateFields", validator.validateTop(TOP, derived), INT_FIELD, "minimum");
+        derived.intValue = INT_VALUE;
+
+        // both invalid
+        derived.strValue = null;
+        derived.intValue = 1;
+        result = validator.validateTop(TOP, derived);
+        assertInvalid("testValidateFields", result, STR_FIELD, "null");
+        assertInvalid("testValidateFields", result, INT_FIELD, "minimum");
+        derived.strValue = STRING_VALUE;
+        derived.intValue = INT_VALUE;
+    }
+
+    @Test
+    public void testValidateField() {
+        /*
+         * Note: nested classes contain fields like "$this", thus the check for "$" in the
+         * variable name is already covered by the other tests.
+         */
+
+        /*
+         * Class with no annotations.
+         */
+        class NoAnnotations {
+            @SuppressWarnings("unused")
+            String strValue;
+        }
+
+        NoAnnotations noAnnot = new NoAnnotations();
+        noAnnot.strValue = null;
+        assertTrue(validator.validateTop(TOP, noAnnot).isValid());
+
+        /*
+         * Class containing a static field with an annotation.
+         */
+        AnnotFieldStatic annotFieldStatic = new AnnotFieldStatic();
+        assertThatIllegalArgumentException().isThrownBy(() -> validator.validateTop(TOP, annotFieldStatic))
+                        .withMessageContaining(STR_FIELD).withMessageContaining("static");
+
+        /*
+         * Class containing a static field, with an annotation at the class level.
+         */
+        AnnotClassStatic annotClassStatic = new AnnotClassStatic();
+        assertTrue(validator.validateTop(TOP, annotClassStatic).isValid());
+
+        /*
+         * Class with no getter method, with field-level annotation.
+         */
+        class NoGetter {
+            @NotNull
+            String strValue;
+        }
+
+        NoGetter noGetter = new NoGetter();
+        assertThatIllegalArgumentException().isThrownBy(() -> validator.validateTop(TOP, noGetter))
+                        .withMessageContaining(STR_FIELD).withMessageContaining(GET_MSG);
+
+        /*
+         * Class with no getter method, with class-level annotation.
+         */
+        @NotNull
+        class ClassNoGetter {
+            @SuppressWarnings("unused")
+            String strValue;
+        }
+
+        ClassNoGetter classNoGetter = new ClassNoGetter();
+        assertTrue(validator.validateTop(TOP, classNoGetter).isValid());
+
+        /*
+         * Class with "blank", but no "null" check. Value is null.
+         */
+        class NoNullCheck {
+            @NotBlank
+            @Getter
+            String strValue;
+        }
+
+        NoNullCheck noNullCheck = new NoNullCheck();
+        assertTrue(validator.validateTop(TOP, noNullCheck).isValid());
+
+        /*
+         * Class with conflicting minimum and maximum, where the value doesn't satisfy
+         * either of them. This should only generate one result, rather than one for each
+         * check. Note: the "max" check occurs before the "min" check, so that's the one
+         * we expect in the result.
+         */
+        class MinAndMax {
+            @Getter
+            @Min(200)
+            @Max(100)
+            Integer intValue;
+        }
+
+        MinAndMax minAndMax = new MinAndMax();
+        minAndMax.intValue = 150;
+        BeanValidationResult result = validator.validateTop(INT_FIELD, minAndMax);
+        assertFalse(result.isValid());
+        assertInvalid("testValidateField", result, INT_FIELD, "maximum");
+        assertFalse(result.getResult().contains("minimum"));
+    }
+
+    @Test
+    public void testGetValue() {
+        /*
+         * Class where the getter throws an exception.
+         */
+        class GetExcept {
+            @NotNull
+            String strValue;
+
+            @SuppressWarnings("unused")
+            public String getStrValue() {
+                throw EXPECTED_EXCEPTION;
+            }
+        }
+
+        GetExcept getExcept = new GetExcept();
+        assertThatIllegalArgumentException().isThrownBy(() -> validator.validateTop(TOP, getExcept))
+                        .withMessageContaining(STR_FIELD).withMessageContaining("accessor threw");
+    }
+
+    @Test
+    public void testVerNotNull() {
+        class NotNullCheck {
+            @Getter
+            @Min(1)
+            @NotNull
+            Integer intValue;
+        }
+
+        NotNullCheck notNullCheck = new NotNullCheck();
+        assertInvalid("testVerNotNull", validator.validateTop(TOP, notNullCheck), INT_FIELD, "null");
+
+        notNullCheck.intValue = INT_VALUE;
+        assertTrue(validator.validateTop(TOP, notNullCheck).isValid());
+
+        notNullCheck.intValue = 0;
+        assertInvalid("testVerNotNull", validator.validateTop(TOP, notNullCheck), INT_FIELD, "minimum");
+    }
+
+    @Test
+    public void testVerNotBlank() {
+        class NotBlankCheck {
+            @Getter
+            @NotBlank
+            String strValue;
+        }
+
+        NotBlankCheck notBlankCheck = new NotBlankCheck();
+
+        // null
+        assertTrue(validator.validateTop(TOP, notBlankCheck).isValid());
+
+        // empty
+        notBlankCheck.strValue = "";
+        assertInvalid("testVerNotNull", validator.validateTop(TOP, notBlankCheck), STR_FIELD, "blank");
+
+        // spaces
+        notBlankCheck.strValue = "  ";
+        assertInvalid("testVerNotNull", validator.validateTop(TOP, notBlankCheck), STR_FIELD, "blank");
+
+        // not blank
+        notBlankCheck.strValue = STRING_VALUE;
+        assertTrue(validator.validateTop(TOP, notBlankCheck).isValid());
+
+        /*
+         * Class with "blank" annotation on an integer.
+         */
+        class NotBlankInt {
+            @Getter
+            @NotBlank
+            int intValue;
+        }
+
+        NotBlankInt notBlankInt = new NotBlankInt();
+        notBlankInt.intValue = 0;
+        assertTrue(validator.validateTop(TOP, notBlankInt).isValid());
+    }
+
+    @Test
+    public void testVerMax() {
+        /*
+         * Field is not a number.
+         */
+        class NonNumeric {
+            @Getter
+            @Max(100)
+            String strValue;
+        }
+
+        NonNumeric nonNumeric = new NonNumeric();
+        nonNumeric.strValue = STRING_VALUE;
+        assertTrue(validator.validateTop(TOP, nonNumeric).isValid());
+
+        /*
+         * Integer field.
+         */
+        class IntField {
+            @Getter
+            @Max(100)
+            Integer intValue;
+        }
+
+        // ok value
+        IntField intField = new IntField();
+        assertNumeric("testVerMax-integer", intField, value -> {
+            intField.intValue = value;
+        }, INT_FIELD, "maximum", INT_VALUE, 100, 101);
+
+        /*
+         * Long field.
+         */
+        class LongField {
+            @Getter
+            @Max(100)
+            Long numValue;
+        }
+
+        // ok value
+        LongField longField = new LongField();
+        assertNumeric("testVerMax-long", longField, value -> {
+            longField.numValue = (long) value;
+        }, NUM_FIELD, "maximum", INT_VALUE, 100, 101);
+
+        /*
+         * Float field.
+         */
+        class FloatField {
+            @Getter
+            @Max(100)
+            Float numValue;
+        }
+
+        // ok value
+        FloatField floatField = new FloatField();
+        assertNumeric("testVerMax-float", floatField, value -> {
+            floatField.numValue = (float) value;
+        }, NUM_FIELD, "maximum", INT_VALUE, 100, 101);
+
+        /*
+         * Double field.
+         */
+        class DoubleField {
+            @Getter
+            @Max(100)
+            Double numValue;
+        }
+
+        // ok value
+        DoubleField doubleField = new DoubleField();
+        assertNumeric("testVerMax-double", doubleField, value -> {
+            doubleField.numValue = (double) value;
+        }, NUM_FIELD, "maximum", INT_VALUE, 100, 101);
+
+        /*
+         * Atomic Integer field (which is a subclass of Number).
+         */
+        class AtomIntValue {
+            @Getter
+            @Max(100)
+            AtomicInteger numValue;
+        }
+
+        // ok value
+        AtomIntValue atomIntField = new AtomIntValue();
+        atomIntField.numValue = new AtomicInteger(INT_VALUE);
+        assertTrue(validator.validateTop(TOP, atomIntField).isValid());
+
+        // invalid value - should be OK, because it isn't an Integer
+        atomIntField.numValue.set(101);
+        assertTrue(validator.validateTop(TOP, atomIntField).isValid());
+    }
+
+    @Test
+    public void testVerMin() {
+        /*
+         * Field is not a number.
+         */
+        class NonNumeric {
+            @Getter
+            @Min(10)
+            String strValue;
+        }
+
+        NonNumeric nonNumeric = new NonNumeric();
+        nonNumeric.strValue = STRING_VALUE;
+        assertTrue(validator.validateTop(TOP, nonNumeric).isValid());
+
+        /*
+         * Integer field.
+         */
+        class IntField {
+            @Getter
+            @Min(10)
+            Integer intValue;
+        }
+
+        // ok value
+        IntField intField = new IntField();
+        assertNumeric("testVerMin-integer", intField, value -> {
+            intField.intValue = value;
+        }, INT_FIELD, "minimum", INT_VALUE, 10, 1);
+
+        /*
+         * Long field.
+         */
+        class LongField {
+            @Getter
+            @Min(10)
+            Long numValue;
+        }
+
+        // ok value
+        LongField longField = new LongField();
+        assertNumeric("testVerMin-long", longField, value -> {
+            longField.numValue = (long) value;
+        }, NUM_FIELD, "minimum", INT_VALUE, 10, 1);
+
+        /*
+         * Float field.
+         */
+        class FloatField {
+            @Getter
+            @Min(10)
+            Float numValue;
+        }
+
+        // ok value
+        FloatField floatField = new FloatField();
+        assertNumeric("testVerMin-float", floatField, value -> {
+            floatField.numValue = (float) value;
+        }, NUM_FIELD, "minimum", INT_VALUE, 10, 1);
+
+        /*
+         * Double field.
+         */
+        class DoubleField {
+            @Getter
+            @Min(10)
+            Double numValue;
+        }
+
+        // ok value
+        DoubleField doubleField = new DoubleField();
+        assertNumeric("testVerMin-double", doubleField, value -> {
+            doubleField.numValue = (double) value;
+        }, NUM_FIELD, "minimum", INT_VALUE, 10, 1);
+
+        /*
+         * Atomic Integer field (which is a subclass of Number).
+         */
+        class AtomIntValue {
+            @Getter
+            @Min(10)
+            AtomicInteger numValue;
+        }
+
+        // ok value
+        AtomIntValue atomIntField = new AtomIntValue();
+        atomIntField.numValue = new AtomicInteger(INT_VALUE);
+        assertTrue(validator.validateTop(TOP, atomIntField).isValid());
+
+        // invalid value - should be OK, because it isn't an Integer
+        atomIntField.numValue.set(101);
+        assertTrue(validator.validateTop(TOP, atomIntField).isValid());
+    }
+
+    private <T> void assertNumeric(String testName, T object, Consumer<Integer> setter, String fieldName,
+                    String expectedText, int inside, int edge, int outside) {
+        setter.accept(inside);
+        assertTrue(validator.validateTop(TOP, object).isValid());
+
+        // on the edge
+        setter.accept(edge);
+        assertTrue(validator.validateTop(TOP, object).isValid());
+
+        // invalid
+        setter.accept(outside);
+        assertInvalid("testVerNotNull", validator.validateTop(TOP, object), fieldName, expectedText);
+    }
+
+    @Test
+    public void testGetAccessor() {
+        /*
+         * Class with "get" method has been tested through-out this junit, so no need to
+         * do more.
+         */
+
+        /*
+         * Class with "is" method.
+         */
+        class IsField {
+            @NotNull
+            Boolean boolValue;
+
+            @SuppressWarnings("unused")
+            public Boolean isBoolValue() {
+                return boolValue;
+            }
+        }
+
+        // ok value
+        IsField isField = new IsField();
+        isField.boolValue = true;
+        assertTrue(validator.validateTop(TOP, isField).isValid());
+
+        // invalid value
+        isField.boolValue = null;
+        assertInvalid("testGetAccessor", validator.validateTop(TOP, isField), BOOL_FIELD, "null");
+    }
+
+    @Test
+    public void testGetMethod() {
+        /*
+         * Class with some fields annotated and some not.
+         */
+        @Getter
+        class Mixed {
+            Integer intValue;
+
+            @NotNull
+            String strValue;
+        }
+
+        // invalid
+        Mixed mixed = new Mixed();
+        BeanValidationResult result = validator.validateTop(TOP, mixed);
+        assertInvalid("testGetMethod", result, STR_FIELD, "null");
+        assertFalse(result.getResult().contains(INT_FIELD));
+
+        // intValue is null, but it isn't annotated so this should be valid
+        mixed.strValue = STRING_VALUE;
+        assertTrue(validator.validateTop(TOP, mixed).isValid());
+    }
+
+    @Test
+    public void testValidMethod() {
+
+        /*
+         * Plain getter.
+         */
+        class PlainGetter {
+            @NotNull
+            @Getter
+            String strValue;
+        }
+
+        // invalid
+        PlainGetter plainGetter = new PlainGetter();
+        assertInvalid("testValidMethod", validator.validateTop(TOP, plainGetter), STR_FIELD, "null");
+
+        // valid
+        plainGetter.strValue = STRING_VALUE;
+        assertTrue(validator.validateTop(TOP, plainGetter).isValid());
+
+        /*
+         * Static getter - should throw an exception.
+         */
+        StaticGetter staticGetter = new StaticGetter();
+        assertThatIllegalArgumentException().isThrownBy(() -> validator.validateTop(TOP, staticGetter))
+                        .withMessageContaining(STR_FIELD).withMessageContaining(GET_MSG);
+
+        /*
+         * Protected getter - should throw an exception.
+         */
+        class ProtectedGetter {
+            @NotNull
+            @Getter(AccessLevel.PROTECTED)
+            String strValue;
+        }
+
+        ProtectedGetter protectedGetter = new ProtectedGetter();
+        assertThatIllegalArgumentException().isThrownBy(() -> validator.validateTop(TOP, protectedGetter))
+                        .withMessageContaining(STR_FIELD).withMessageContaining(GET_MSG);
+
+        /*
+         * getter is a "void" function - should throw an exception.
+         */
+        class VoidGetter {
+            @NotNull
+            String strValue;
+
+            @SuppressWarnings("unused")
+            public void getStrValue() {
+                // do nothing
+            }
+        }
+
+        VoidGetter voidGetter = new VoidGetter();
+        assertThatIllegalArgumentException().isThrownBy(() -> validator.validateTop(TOP, voidGetter))
+                        .withMessageContaining(STR_FIELD).withMessageContaining(GET_MSG);
+
+        /*
+         * getter takes an argument - should throw an exception.
+         */
+        class ArgGetter {
+            @NotNull
+            String strValue;
+
+            @SuppressWarnings("unused")
+            public String getStrValue(String echo) {
+                return echo;
+            }
+        }
+
+        ArgGetter argGetter = new ArgGetter();
+        assertThatIllegalArgumentException().isThrownBy(() -> validator.validateTop(TOP, argGetter))
+                        .withMessageContaining(STR_FIELD).withMessageContaining(GET_MSG);
+    }
+
+
+    private void assertInvalid(String testName, BeanValidationResult result, String fieldName, String message) {
+        String text = result.getResult();
+        assertNotNull(testName, text);
+        assertTrue(testName, text.contains(fieldName));
+        assertTrue(testName, text.contains(message));
+    }
+
+    /**
+     * Annotated static field.
+     */
+    private static class AnnotFieldStatic {
+        @NotNull
+        static String strValue;
+    }
+
+    /**
+     * Annotated class with a static field.
+     */
+    @NotNull
+    private static class AnnotClassStatic {
+        @SuppressWarnings("unused")
+        static String strValue;
+    }
+
+    /**
+     * Class with an annotated field, but a static "getter".
+     */
+    private static class StaticGetter {
+        @NotNull
+        String strValue;
+
+        @SuppressWarnings("unused")
+        public static String getStrValue() {
+            return STRING_VALUE;
+        }
+    }
+}