com.nestedbird.modules.formparser.FormParse.java Source code

Java tutorial

Introduction

Here is the source code for com.nestedbird.modules.formparser.FormParse.java

Source

/*
 *  NestedBird  Copyright (C) 2016-2017  Michael Haddon
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU Affero General Public License version 3
 *  as published by the Free Software Foundation.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU Affero General Public License for more details.
 *
 *  You should have received a copy of the GNU Affero General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.nestedbird.modules.formparser;

import com.nestedbird.models.core.Base.BaseEntity;
import com.nestedbird.modules.schema.annotations.SchemaRepository;
import com.nestedbird.modules.schema.annotations.SchemaView;
import com.nestedbird.util.JSONUtil;
import com.nestedbird.util.Mutable;
import com.nestedbird.util.UUIDConverter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.math.NumberUtils;
import org.joda.time.DateTime;
import org.joda.time.Period;
import org.joda.time.format.DateTimeFormat;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.springframework.beans.PropertyAccessor;
import org.springframework.beans.PropertyAccessorFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.ResolvableType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.thymeleaf.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.*;

/**
 * This class processes the payload of a form and saves the data to the database
 * Because different variable types may be processed differently, this class supports methods for handling
 * all sorts of different datatypes and situations.
 */
@Configuration
@Slf4j
public class FormParse {
    /**
     * The application context so we can search for beans
     */
    private final ApplicationContext appContext;

    /**
     * Instantiates a new Form parse.
     *
     * @param appContext the app context
     */
    @Autowired
    public FormParse(final ApplicationContext appContext) {
        this.appContext = appContext;
    }

    /**
     * Parses a HTTPServletRequest and saves the information onto an existing entity
     *
     * @param <T>            - Class of entity
     * @param existingEntity - Entity to be written over
     * @param request        - HTTPRequest information
     * @return - Entity with new information
     */
    public <T extends BaseEntity> T parse(final T existingEntity, final HttpServletRequest request) {
        final ParameterMapParser parser = ParameterMapParser.parse(request.getParameterMap());
        parser.loopData((key, value) -> writeToEntity(existingEntity, key, value));

        return existingEntity;
    }

    /**
     * Write the value to the existingEntity field with the name of key
     *
     * @param <T>            Type of the entity
     * @param existingEntity The entity we are changing
     * @param key            The key we are changing
     * @param value          The new value
     */
    private <T extends BaseEntity> void writeToEntity(T existingEntity, String key, Object value) {
        final PropertyAccessor accessor = PropertyAccessorFactory.forDirectFieldAccess(existingEntity);

        if (accessor.getPropertyType(key) != null) {
            try {
                if (value.getClass().equals(JSONObject.class) && ((JSONObject) value).has("_isMap")
                        && ((JSONObject) value).get("_isMap").equals(true)) {
                    writeArrayMapToEntity(accessor, key, (JSONObject) value);
                } else if (value.getClass().equals(JSONObject.class)) {
                    writeObjectToEntity(accessor, key, (JSONObject) value);
                } else if (value.getClass().equals(JSONArray.class)) {
                    writeArrayToEntity(accessor, key, (JSONArray) value);
                } else if (isFieldValid(accessor, key, existingEntity.getClass())) {
                    writeValueToEntity(accessor, key, value);
                }
            } catch (JSONException e) {
                logger.info("[FormParse] [writeToEntity] Unable To Process JSON", e);
            }
        }
    }

    /**
     * Write object to the entity
     *
     * @param accessor The accessor for the existing entity
     * @param key      The fields name we are overwriting
     * @param value    The new value
     * @throws JSONException the json exception
     */
    private void writeObjectToEntity(final PropertyAccessor accessor, final String key, final JSONObject value)
            throws JSONException {
        final ResolvableType type = accessor.getPropertyTypeDescriptor(key).getResolvableType();

        accessor.setPropertyValue(key, parseObject(value, type));
    }

    /**
     * Write an array map to an entity
     * an array map is an object, but is meant to represent an array
     *
     * @param accessor The accessor for the existing entity
     * @param key      The fields name we are overwriting
     * @param value    The new value
     * @throws JSONException the json exception
     */
    private void writeArrayMapToEntity(final PropertyAccessor accessor, final String key, final JSONObject value)
            throws JSONException {
        final ResolvableType type = accessor.getPropertyTypeDescriptor(key).getResolvableType();

        accessor.setPropertyValue(key, parseArrayMap(value, type));
    }

    /**
     * Write array to entity.
     *
     * @param accessor The accessor for the existing entity
     * @param key      The fields name we are overwriting
     * @param value    The new value
     * @throws JSONException the json exception
     */
    private void writeArrayToEntity(final PropertyAccessor accessor, final String key, final JSONArray value)
            throws JSONException {
        final ResolvableType type = accessor.getPropertyTypeDescriptor(key).getResolvableType();

        accessor.setPropertyValue(key, parseArray(value, type));
    }

    /**
     * Write normal value to entity.
     *
     * @param accessor The accessor for the existing entity
     * @param key      The fields name we are overwriting
     * @param value    The new value
     */
    private void writeValueToEntity(final PropertyAccessor accessor, final String key, final Object value) {
        final ResolvableType type = accessor.getPropertyTypeDescriptor(key).getResolvableType();

        accessor.setPropertyValue(key, parseValue(value, type));
    }

    /**
     * Is value array an array of database entities
     *
     * @param type type of value
     * @return the boolean
     */
    private Boolean isValueArrayOfDatabaseEntities(final Class type) {
        return BaseEntity.class.isAssignableFrom(type) && type.getAnnotation(SchemaRepository.class) != null;
    }

    /**
     * Is value a database entity
     *
     * @param type type of value
     * @return the boolean
     */
    private Boolean isValueDatabaseEntity(final Class type) {
        return BaseEntity.class.isAssignableFrom(type) && type.getAnnotation(SchemaRepository.class) != null;
    }

    /**
     * Is value a period.
     *
     * @param type type of value
     * @return the boolean
     */
    private Boolean isValuePeriod(final ResolvableType type) {
        return type.getRawClass().isAssignableFrom(Period.class);
    }

    /**
     * Is value an enum
     *
     * @param type type of the value
     * @return boolean
     */
    private Boolean isValueEnum(final ResolvableType type) {
        return Enum.class.isAssignableFrom(type.getRawClass());
    }

    /**
     * Is value a database index
     *
     * @param type the type of value
     * @return boolean
     */
    private Boolean isValueDatabaseIndex(final ResolvableType type) {
        return type.getRawClass().getAnnotation(SchemaRepository.class) != null;
    }

    /**
     * Is value a JODA DateTime
     *
     * @param value the value
     * @param type  the type of the value
     * @return boolean
     */
    private Boolean isValueDateTime(final Object value, final ResolvableType type) {
        return (type.getRawClass().isAssignableFrom(DateTime.class)) && (value.toString().length() > 0);
    }

    /**
     * Is value a comma separated value of database indexes
     *
     * @param type the type of the value
     * @return boolean
     */
    private Boolean isValueCSVDatabaseIndexes(final ResolvableType type) {
        return java.util.Collection.class.isAssignableFrom(type.getRawClass()) && (type.getGeneric(0) != null)
                && (type.getGeneric(0).getRawClass().getAnnotation(SchemaRepository.class) != null);
    }

    /**
     * Some Types can just be written instantly to the class, this method tells you if a type does not need
     * additional processing
     *
     * @param fieldType - The type of field
     * @return boolean boolean
     */
    private Boolean isTypeStandard(final Class fieldType) {
        final List<Class> standardTypes = Arrays.asList(String.class, Long.class, Integer.class, Double.class,
                Boolean.class);
        return standardTypes.contains(fieldType);
    }

    /**
     * Checks whether the target field is final, meaning it should not be written over...
     * apparently spring writes over final variables
     *
     * @param objectClass - The class of the object we are checking
     * @param fieldName   - The name of the field we are checking
     * @return - Whether or not the field is final
     */
    private boolean isFieldFinal(final Class objectClass, final String fieldName) {
        final Mutable<Boolean> returnVar = Mutable.of(true);
        getField(objectClass, fieldName)
                .ifPresent(field -> returnVar.mutate(Modifier.isFinal(field.getModifiers())));
        return returnVar.get();
    }

    /**
     * Is the field editable according to the SchemaView annotation
     *
     * @param accessor the accessor
     * @param key      the key
     * @return boolean boolean
     */
    private Boolean isFieldEditable(final PropertyAccessor accessor, final String key) {
        final SchemaView schemaView = accessor.getPropertyTypeDescriptor(key).getAnnotation(SchemaView.class);

        if (schemaView != null) {
            final boolean isLocked = schemaView.locked() && accessor.getPropertyValue(key) != null;
            final boolean isVisible = schemaView.visible();

            return !isLocked && isVisible;
        }

        return false;
    }

    /**
     * Can the field be written to and read from?
     * This is according to the springs accessor, if it has no setter or getter it still can somehow be read to...
     * apparently
     *
     * @param accessor  - The spring propertyAccessor          -
     * @param fieldName - The fieldName
     * @return boolean boolean
     */
    private Boolean isFieldInteractable(final PropertyAccessor accessor, final String fieldName) {
        return accessor.isReadableProperty(fieldName) && accessor.isWritableProperty(fieldName);
    }

    /**
     * Is field valid for editing
     *
     * @param accessor    the accessor
     * @param fieldName   the field name
     * @param entityClass the entity class
     * @return the boolean
     */
    private boolean isFieldValid(final PropertyAccessor accessor, final String fieldName, final Class entityClass) {
        return isFieldInteractable(accessor, fieldName) && isFieldEditable(accessor, fieldName)
                && doesSetterExist(entityClass, fieldName, accessor.getPropertyType(fieldName))
                && !isFieldFinal(entityClass, fieldName);
    }

    /**
     * Saves and associate these database entities to this object
     *
     * @param value database entities to process
     * @param type  type of the database entities
     * @return new list of database entities
     * @throws JSONException the exception
     */
    private List<Object> parseArrayOfDatabaseEntities(final JSONArray value, final Class type)
            throws JSONException {
        final List<Object> elements = new ArrayList<>();

        for (Integer i = 0; i < value.length(); i++) {
            if (!value.isNull(i)) {
                final JSONObject data = (JSONObject) value.get(i);
                elements.add(parseBaseEntity(data, type));
            }
        }
        return elements;
    }

    /**
     * processes an array map
     *
     * @param value value to process
     * @param type  type of this array
     * @return new list
     * @throws JSONException the exception
     */
    private Object parseArrayMap(final JSONObject value, final ResolvableType type) throws JSONException {
        final JSONArray newArray = new JSONArray();

        JSONUtil.loopObjectData(value, (key, data) -> {
            if (NumberUtils.isCreatable(key)) {
                newArray.put(data);
            }
        });

        return parseArray(newArray, type);
    }

    /**
     * processes an array
     *
     * @param value value to process
     * @param type  type of this array
     * @return new list
     * @throws JSONException the exception
     */
    private Object parseArray(final JSONArray value, final ResolvableType type) throws JSONException {
        final List<Object> elements = new ArrayList<>();
        if (isValueArrayOfDatabaseEntities(type.getGeneric(0).getRawClass())) {
            elements.addAll(parseArrayOfDatabaseEntities(value, type.getGeneric(0).getRawClass()));
        }
        return elements;
    }

    /**
     * Updates a BaseEntity in the database
     *
     * @param value     data to edit
     * @param fieldType type of baseentity
     * @return edited base entity
     * @throws JSONException the exception
     */
    private BaseEntity parseBaseEntity(final JSONObject value, final Class fieldType) throws JSONException {
        final JpaRepository repository = getFieldRepository(fieldType);
        Optional<BaseEntity> entity = Optional.empty();

        if (value.has("id") && (!value.isNull("id"))) {
            final String id = value.get("id").toString();
            entity = Optional.ofNullable((BaseEntity) repository.findOne(id));
        }

        if (!entity.isPresent()) {
            try {
                entity = Optional.ofNullable((BaseEntity) Class.forName(fieldType.getName()).newInstance());
            } catch (InstantiationException | ClassNotFoundException | IllegalAccessException e1) {
                logger.info("[FormParse] [parseBaseEntity] Failure To Create Class Instance", e1);
            }
        }
        entity.ifPresent(e -> {
            final ParameterMapParser parser = new ParameterMapParser(value);
            parser.loopData((key, data) -> writeToEntity(e, key, data));
            repository.saveAndFlush(e);
        });

        return entity.orElseGet(null);
    }

    /**
     * Parses an Object
     *
     * @param value the value
     * @param type  the type of the value
     * @return new Object
     * @throws JSONException the exception
     */
    private Object parseObject(final JSONObject value, final ResolvableType type) throws JSONException {
        Object returnVar = value;
        if (isValueDatabaseEntity(type.getClass())) {
            returnVar = parseBaseEntity(value, type.getClass());
        }
        return returnVar;
    }

    /**
     * Parses a simple value
     *
     * @param value the value
     * @param type  the value type
     * @return parsed result
     */
    private Object parseValue(final Object value, final ResolvableType type) {
        Object returnVar = null;
        if (isTypeStandard(type.getRawClass())) {
            returnVar = value;
        } else if (isValueCSVDatabaseIndexes(type)) {
            returnVar = parseCSVDatabaseIndexes(value.toString(), type.getGeneric(0).getRawClass());
        } else if (isValueDateTime(value, type)) {
            returnVar = parseDateTime(value);
        } else if (isValueDatabaseIndex(type)) {
            returnVar = parseDatabaseIndex(value.toString(), type.getRawClass());
        } else if (isValuePeriod(type)) {
            returnVar = parsePeriod(value.toString());
        } else if (isValueEnum(type)) {
            returnVar = parseEnum(value.toString(), type.getRawClass());
        }
        return returnVar;
    }

    /**
     * Parse Period
     *
     * @param value value to parse
     * @return new Period
     */
    private Period parsePeriod(final String value) {
        return Period.seconds(Integer.parseInt(value));
    }

    /**
     * Parse enum
     *
     * @param value value of enum
     * @param type  type of enum
     * @return enum index
     */
    @SuppressWarnings("unchecked")
    private Object parseEnum(final String value, final Class type) {
        return Enum.valueOf(type, value);
    }

    /**
     * Parse Database Index
     * Retrieves the object from the database so we can save the object to the entity
     *
     * @param value           entity id
     * @param repositoryClass repository of entity
     * @return the entity
     */
    private Object parseDatabaseIndex(final String value, final Class repositoryClass) {
        final JpaRepository repository = getFieldRepository(repositoryClass);
        return repository.findOne(value);
    }

    /**
     * Parse JODA DateTime
     *
     * @param value joda datetime value
     * @return new Joda DateTime
     */
    private DateTime parseDateTime(final Object value) {
        return DateTime.parse(value.toString(), DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss"));
    }

    /**
     * Parses a comma separated value of database indexes.
     * Retrieves all the items from the database and adds them to an array to save them to the object
     *
     * @param value           CSV of indexes
     * @param repositoryClass repository of entities
     * @return array of entities
     */
    private Collection<BaseEntity> parseCSVDatabaseIndexes(final String value, final Class repositoryClass) {
        final Set<BaseEntity> elements = new HashSet<>();
        final JpaRepository repository = getFieldRepository(repositoryClass);
        Arrays.stream(value.split(",")).distinct().filter(Objects::nonNull).filter(UUIDConverter::isUUID)
                .forEach(id -> elements.add((BaseEntity) repository.getOne(id)));
        return elements;
    }

    /**
     * Finds the JpaRepository for a class
     *
     * @param entityType - Class we want to find a JpaRepository for
     * @return field repository
     */
    private JpaRepository getFieldRepository(final Class<?> entityType) {
        final Class repositoryClass = entityType.getAnnotation(SchemaRepository.class).value();
        return (JpaRepository) appContext.getBean(repositoryClass);
    }

    /**
     * For some reason I cannot use Class.getField, as it does not do anything.
     * This method loops over DeclaredFields of the object and all its parents
     *
     * @param objectClass - The object we are searching in
     * @param fieldName   - The fields name we are searching for
     * @return - The field
     */
    private Optional<Field> getField(final Class objectClass, final String fieldName) {
        Class currentClass = objectClass;
        final Mutable<Field> field = Mutable.of(null);
        do {
            final Field foundField = Arrays.stream(currentClass.getDeclaredFields())
                    .filter(e -> e.getName().equals(fieldName)).findFirst().orElse(null);

            field.mutateIf(foundField, foundField != null);

            currentClass = currentClass.getSuperclass();
        } while (currentClass.getSuperclass() != null);

        if (!field.isPresent()) {
            logger.info("[FormParse] [getField] Unable To Find Field", new NoSuchFieldException(fieldName));
        }

        return field.ofNullable();
    }

    /**
     * Find the fields setter method. This is a simple search and it looks for get plus the variable name capitalised.
     * For example
     * name     => setName
     * bigName  => setBigName
     *
     * @param objectClass - The object we are searching in
     * @param fieldName   - The name of the field we want to find the setter for
     * @param fieldType   the field type
     * @return - The setter method
     * @throws NoSuchMethodException the no such method exception
     */
    private Method getFieldSetter(final Class<?> objectClass, final String fieldName, final Class fieldType)
            throws NoSuchMethodException {
        return objectClass.getMethod("set" + StringUtils.capitalize(fieldName), fieldType);
    }

    /**
     * When checking for a fields setter, we need to specify the class of the variable we expect the setter to
     * take, so we know if it will accept the variable we expect it to.
     * Certain setters use different variable types in setting than the actual variables type.
     * For example, DateTime sets its variables with strings rather than DateTime.
     *
     * @param fieldType - The type of variable of the field
     * @return - The type of variable we will use in the setter
     */
    private Class<?> getFieldSetterType(final Class<?> fieldType) {
        Class<?> returnClass = fieldType;

        if (fieldType.isAssignableFrom(DateTime.class)) {
            returnClass = String.class;
        }

        return returnClass;
    }

    /**
     * Returns whether or not a field has a getter
     *
     * @param objectClass - The object we are searching in
     * @param fieldName   - The name of the field we want to find the getter for
     * @return boolean boolean
     */
    private Boolean doesGetterExist(final Class<?> objectClass, final String fieldName) {
        try {
            getFieldGetter(objectClass, fieldName);
            return true;
        } catch (NoSuchMethodException e) {
            return false;
        }
    }

    /**
     * Find the fields getter method. This is a simple search and it looks for get plus the variable name capitalised.
     * For example
     * name     => getName
     * bigName  => getBigName
     *
     * @param objectClass - The object we are searching in
     * @param fieldName   - The name of the field we want to find the getter for
     * @return - The getter method
     * @throws NoSuchMethodException the no such method exception
     */
    private Method getFieldGetter(final Class<?> objectClass, final String fieldName) throws NoSuchMethodException {
        return objectClass.getMethod("get" + StringUtils.capitalize(fieldName));
    }

    /**
     * Returns whether or not a field has a setter
     *
     * @param objectClass - The object we are searching in
     * @param fieldName   - The name of the field we want to find the setter for
     * @param fieldType   the field type
     * @return boolean boolean
     */
    private Boolean doesSetterExist(final Class<?> objectClass, final String fieldName, final Class fieldType) {
        try {
            // This intentionally doesn't immediately return, so the try can trip without returning
            getFieldSetter(objectClass, fieldName, getFieldSetterType(fieldType));
            return true;
        } catch (NoSuchMethodException e) {
            return false;
        }
    }
}