XWorkConverter.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *  http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.struts2.conversion.impl;

import org.apache.struts2.ActionContext;
import org.apache.struts2.FileManager;
import org.apache.struts2.FileManagerFactory;
import org.apache.struts2.text.LocalizedTextProvider;
import org.apache.struts2.conversion.ConversionAnnotationProcessor;
import org.apache.struts2.conversion.ConversionFileProcessor;
import org.apache.struts2.conversion.TypeConverter;
import org.apache.struts2.conversion.TypeConverterHolder;
import org.apache.struts2.conversion.annotations.Conversion;
import org.apache.struts2.conversion.annotations.TypeConversion;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.util.AnnotationUtils;
import org.apache.struts2.util.ClassLoaderUtil;
import org.apache.struts2.util.CompoundRoot;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.util.reflection.ReflectionContextState;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.StrutsConstants;

import java.lang.annotation.Annotation;
import java.lang.reflect.Member;
import java.lang.reflect.Method;
import java.net.URL;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;


/**
 * <p>
 * XWorkConverter is a singleton used by many of the Struts 2's Ognl extension points,
 * such as InstantiatingNullHandler, XWorkListPropertyAccessor etc to do object
 * conversion.
 * </p>
 *
 * <!-- START SNIPPET: javadoc -->
 *
 * <p>
 * Type conversion is great for situations where you need to turn a String in to a more complex object. Because the web
 * is type-agnostic (everything is a string in HTTP), Struts 2's type conversion features are very useful. For instance,
 * if you were prompting a user to enter in coordinates in the form of a string (such as "3, 22"), you could have
 * Struts 2 do the conversion both from String to Point and from Point to String.
 * </p>
 *
 * <p>
 * Using this "point" example, if your action (or another compound object in which you are setting properties on)
 * has a corresponding ClassName-conversion.properties file, Struts 2 will use the configured type converters for
 * conversion to and from strings. So turning "3, 22" in to new Point(3, 22) is done by merely adding the following
 * entry to <b>ClassName-conversion.properties</b> (Note that the PointConverter should impl the TypeConverter
 * interface):
 * </p>
 * <p><b>point = com.acme.PointConverter</b></p>
 *
 * <p>
 * Your type converter should be sure to check what class type it is being requested to convert. Because it is used
 * for both to and from strings, you will need to split the conversion method in to two parts: one that turns Strings in
 * to Points, and one that turns Points in to Strings.
 * </p>
 *
 * <p>
 * After this is done, you can now reference your point (using &lt;s:property value="point"/&gt; in JSP or ${point}
 * in FreeMarker) and it will be printed as "3, 22" again. As such, if you submit this back to an action, it will be
 * converted back to a Point once again.
 * </p>
 *
 * <p>
 * In some situations you may wish to apply a type converter globally. This can be done by editing the file
 * <b>xwork-conversion.properties</b> in the root of your class path (typically WEB-INF/classes) and providing a
 * property in the form of the class name of the object you wish to convert on the left hand side and the class name of
 * the type converter on the right hand side. For example, providing a type converter for all Point objects would mean
 * adding the following entry:
 * </p>
 *
 * <p><b>com.acme.Point = com.acme.PointConverter</b></p>
 *
 * <!-- END SNIPPET: javadoc -->
 *
 *
 * <!-- START SNIPPET: i18n-note -->
 * <p>
 * Type conversion should not be used as a substitute for i18n. It is not recommended to use this feature to print out
 * properly formatted dates. Rather, you should use the i18n features of Struts 2 (and consult the JavaDocs for JDK's
 * MessageFormat object) to see how a properly formatted date should be displayed.
 * </p>
 * <!-- END SNIPPET: i18n-note -->
 *
 *
 * <!-- START SNIPPET: error-reporting -->
 * <p>
 * Any error that occurs during type conversion may or may not wish to be reported. For example, reporting that the
 * input &quot;abc&quot; could not be converted to a number might be important. On the other hand, reporting that an empty string,
 * &quot;&quot;, cannot be converted to a number might not be important - especially in a web environment where it is hard to
 * distinguish between a user not entering a value vs. entering a blank value.
 * </p>
 * <p> By default, all conversion errors are reported using the generic i18n key <b>xwork.default.invalid.fieldvalue</b>,
 * which you can override (the default text is <i>Invalid field value for field "xxx"</i>, where xxx is the field name)
 * in your global i18n resource bundle.
 * </p>
 *
 * <p>However, sometimes you may wish to override this message on a per-field basis. You can do this by adding an i18n
 * key associated with just your action (Action.properties) using the pattern <b>invalid.fieldvalue.xxx</b>, where xxx
 * is the field name.
 * </p>
 *
 * <p>It is important to know that none of these errors are actually reported directly. Rather, they are added to a map
 * called <i>conversionErrors</i> in the ActionContext. There are several ways this map can then be accessed and the
 * errors can be reported accordingly.
 * </p>
 * <!-- END SNIPPET: error-reporting -->
 *
 * @author <a href="mailto:plightbo@gmail.com">Pat Lightbody</a>
 * @author Rainer Hermanns
 * @author <a href='mailto:the_mindstorm[at]evolva[dot]ro'>Alexandru Popescu</a>
 * @author tm_jee
 * @version $Date$ $Id$
 * @see XWorkBasicConverter
 */
public class XWorkConverter extends DefaultTypeConverter {

    private static final Logger LOG = LogManager.getLogger(XWorkConverter.class);

    public static final String REPORT_CONVERSION_ERRORS = "report.conversion.errors";
    public static final String CONVERSION_PROPERTY_FULLNAME = "conversion.property.fullName";
    public static final String CONVERSION_ERROR_PROPERTY_PREFIX = "invalid.fieldvalue.";
    public static final String CONVERSION_COLLECTION_PREFIX = "Collection_";

    public static final String LAST_BEAN_CLASS_ACCESSED = "last.bean.accessed";
    public static final String LAST_BEAN_PROPERTY_ACCESSED = "last.property.accessed";
    public static final String MESSAGE_INDEX_PATTERN = "\\[\\d+]\\.";
    public static final String MESSAGE_INDEX_BRACKET_PATTERN = "[\\[\\].]";
    public static final String PERIOD = ".";
    public static final Pattern messageIndexPattern = Pattern.compile(MESSAGE_INDEX_PATTERN);

    private TypeConverter defaultTypeConverter;
    private FileManager fileManager;
    private boolean reloadingConfigs;

    private ConversionFileProcessor fileProcessor;
    private ConversionAnnotationProcessor annotationProcessor;

    private TypeConverterHolder converterHolder;

    protected XWorkConverter() {
    }

    @Inject
    public void setDefaultTypeConverter(XWorkBasicConverter converter) {
        this.defaultTypeConverter = converter;
    }

    @Inject
    public void setFileManagerFactory(FileManagerFactory fileManagerFactory) {
        this.fileManager = fileManagerFactory.getFileManager();
    }

    @Inject(value = StrutsConstants.STRUTS_CONFIGURATION_XML_RELOAD, required = false)
    public void setReloadingConfigs(String reloadingConfigs) {
        this.reloadingConfigs = Boolean.parseBoolean(reloadingConfigs);
    }

    @Inject
    public void setConversionFileProcessor(ConversionFileProcessor fileProcessor) {
        this.fileProcessor = fileProcessor;
    }

    @Inject
    public void setConversionAnnotationProcessor(ConversionAnnotationProcessor annotationProcessor) {
        this.annotationProcessor = annotationProcessor;
    }

    @Inject
    public void setTypeConverterHolder(TypeConverterHolder converterHolder) {
        this.converterHolder = converterHolder;
    }

    public static String getConversionErrorMessage(String propertyName, Class toClass, ValueStack stack) {
        LocalizedTextProvider localizedTextProvider = ActionContext.getContext().getContainer().getInstance(LocalizedTextProvider.class);
        String defaultMessage = localizedTextProvider.findDefaultText("xwork.default.invalid.fieldvalue",
                ActionContext.getContext().getLocale(),
                new Object[]{
                        propertyName
                });

        List<String> indexValues = getIndexValues(propertyName);

        propertyName = removeAllIndexesInPropertyName(propertyName);

        String prefixedPropertyName = CONVERSION_ERROR_PROPERTY_PREFIX + propertyName;
        String getTextExpression = "getText('" + prefixedPropertyName + "')";
        String message = (String) stack.findValue(getTextExpression);

        if (message == null || prefixedPropertyName.equals(message)) {
            getTextExpression = "getText('" + CONVERSION_ERROR_PROPERTY_PREFIX + toClass.getName() + "','" + defaultMessage + "')";
            message = (String) stack.findValue(getTextExpression);
        }

        if (message == null) {
            message = defaultMessage;
        } else {
            message = MessageFormat.format(message, indexValues.toArray());
        }

        return message;
    }

    private static String removeAllIndexesInPropertyName(String propertyName) {
        return propertyName.replaceAll(MESSAGE_INDEX_PATTERN, PERIOD);
    }

    private static List<String> getIndexValues(String propertyName) {
        Matcher matcher = messageIndexPattern.matcher(propertyName);
        List<String> indexes = new ArrayList<>();
        while (matcher.find()) {
            int index = Integer.parseInt(matcher.group().replaceAll(MESSAGE_INDEX_BRACKET_PATTERN, "")) + 1;
            indexes.add(Integer.toString(index));
        }
        return indexes;
    }

    public String buildConverterFilename(Class clazz) {
        String className = clazz.getName();
        return className.replace('.', '/') + "-conversion.properties";
    }

    @Override
    public Object convertValue(Map<String, Object> map, Object o, Class aClass) {
        return convertValue(map, null, null, null, o, aClass);
    }

    /**
     * Convert value from one form to another.
     * Minimum requirement of arguments:
     * <ul>
     * <li>supplying context, toClass and value</li>
     * <li>supplying context, target and value.</li>
     * </ul>
     *
     * @see TypeConverter#convertValue(java.util.Map, java.lang.Object, java.lang.reflect.Member, java.lang.String, java.lang.Object, java.lang.Class)
     */
    @Override
    public Object convertValue(Map<String, Object> context, Object target, Member member, String property, Object value, Class toClass) {
        //
        // Process the conversion using the default mappings, if one exists
        //
        TypeConverter tc = null;

        if ((value != null) && (toClass == value.getClass())) {
            return value;
        }

        // allow this method to be called without any context
        // i.e. it can be called with as little as "Object value" and "Class toClass"
        if (target != null) {
            Class clazz = target.getClass();

            Object[] classProp = null;

            // this is to handle weird issues with setValue with a different type
            if ((target instanceof CompoundRoot) && (context != null)) {
                classProp = getClassProperty(context);
            }

            if (classProp != null) {
                clazz = (Class) classProp[0];
                property = (String) classProp[1];
            }

            tc = (TypeConverter) getConverter(clazz, property);
            LOG.debug("field-level type converter for property [{}] = {}", property, (tc == null ? "none found" : tc));
        }

        if (tc == null && context != null) {
            // ok, let's see if we can look it up by path as requested in XW-297
            Object lastPropertyPath = context.get(ReflectionContextState.CURRENT_PROPERTY_PATH);
            Class clazz = (Class) context.get(XWorkConverter.LAST_BEAN_CLASS_ACCESSED);
            if (lastPropertyPath != null && clazz != null) {
                String path = lastPropertyPath + "." + property;
                tc = (TypeConverter) getConverter(clazz, path);
            }
        }

        if (tc == null) {
            if (toClass.equals(String.class) && value != null && !value.getClass().equals(String[].class)) {
                // when converting to a string, use the source target's class's converter
                tc = lookup(value.getClass());
            } else {
                // when converting from a string, use the toClass's converter
                tc = lookup(toClass);
            }

            if (LOG.isDebugEnabled())
                LOG.debug("global-level type converter for property [{}] = {} ", property, (tc == null ? "none found" : tc));
        }


        if (tc != null) {
            try {
                return tc.convertValue(context, target, member, property, value, toClass);
            } catch (Exception e) {
                LOG.debug("Unable to convert value using type converter [{}]", tc.getClass().getName(), e);
                handleConversionException(context, property, value, target, toClass);

                return TypeConverter.NO_CONVERSION_POSSIBLE;
            }
        }

        if (defaultTypeConverter != null) {
            try {
                LOG.debug("Falling back to default type converter [{}]", defaultTypeConverter);
                return defaultTypeConverter.convertValue(context, target, member, property, value, toClass);
            } catch (Exception e) {
                LOG.debug("Unable to convert value using type converter [{}]", defaultTypeConverter.getClass().getName(), e);
                handleConversionException(context, property, value, target, toClass);

                return TypeConverter.NO_CONVERSION_POSSIBLE;
            }
        } else {
            try {
                LOG.debug("Falling back to Ognl's default type conversion");
                return super.convertValue(value, toClass);
            } catch (Exception e) {
                LOG.debug("Unable to convert value using type converter [{}]", super.getClass().getName(), e);
                handleConversionException(context, property, value, target, toClass);

                return TypeConverter.NO_CONVERSION_POSSIBLE;
            }
        }
    }

    /**
     * Looks for a TypeConverter in the default mappings.
     *
     * @param className name of the class the TypeConverter must handle
     * @param isPrimitive is primitive?
     * @return a TypeConverter to handle the specified class or null if none can be found
     */
    public TypeConverter lookup(String className, boolean isPrimitive) {
        if (converterHolder.containsUnknownMapping(className) && !converterHolder.containsDefaultMapping(className)) {
            return null;
        }

        TypeConverter result = converterHolder.getDefaultMapping(className);

        //Looks for super classes
        if (result == null && !isPrimitive) {
            Class clazz = null;

            try {
                clazz = Thread.currentThread().getContextClassLoader().loadClass(className);
            } catch (ClassNotFoundException cnfe) {
                LOG.debug("Cannot load class {}", className, cnfe);
            }

            result = lookupSuper(clazz);

            if (result != null) {
                //Register now, the next lookup will be faster
                registerConverter(className, result);
            } else {
                // if it isn't found, never look again (also faster)
                registerConverterNotFound(className);
            }
        }

        return result;
    }

    /**
     * Looks for a TypeConverter in the default mappings.
     *
     * @param clazz the class the TypeConverter must handle
     * @return a TypeConverter to handle the specified class or null if none can be found
     */
    public TypeConverter lookup(Class clazz) {
        TypeConverter result = lookup(clazz.getName(), clazz.isPrimitive());

        if (result == null && clazz.isPrimitive()) {
            /**
             * if it is primitive use default converter which allows to define different converters per type
             * @see XWorkBasicConverter
             */
            return defaultTypeConverter;
        }

        return result;
    }

    protected Object getConverter(Class clazz, String property) {
        LOG.debug("Retrieving convert for class [{}] and property [{}]", clazz, property);

        synchronized (clazz) {
            if ((property != null) && !converterHolder.containsNoMapping(clazz)) {
                try {
                    Map<String, Object> mapping = converterHolder.getMapping(clazz);

                    if (mapping == null) {
                        mapping = buildConverterMapping(clazz);
                    } else {
                        mapping = conditionalReload(clazz, mapping);
                    }

                    Object converter = mapping.get(property);
                    if (converter == null && LOG.isDebugEnabled()) {
                        LOG.debug("Converter is null for property [{}]. Mapping size [{}]:", property, mapping.size());
                        for (Map.Entry<String, Object> entry : mapping.entrySet()) {
                            LOG.debug("{}:{}", entry.getKey(), entry.getValue());
                        }
                    }
                    return converter;
                } catch (Throwable t) {
                    LOG.debug("Got exception trying to resolve convert for class [{}] and property [{}]", clazz, property, t);
                    converterHolder.addNoMapping(clazz);
                }
            }
        }
        return null;
    }

    protected void handleConversionException(Map<String, Object> context, String property, Object value, Object object, Class toClass) {
        if (context != null && (Boolean.TRUE.equals(context.get(REPORT_CONVERSION_ERRORS)))) {
            String realProperty = property;
            String fullName = (String) context.get(CONVERSION_PROPERTY_FULLNAME);

            if (fullName != null) {
                realProperty = fullName;
            }

            ActionContext actionContext = ActionContext.of(context);
            Map<String, ConversionData> conversionErrors = actionContext.getConversionErrors();

            if (conversionErrors == null) {
                conversionErrors = new HashMap<>();
                actionContext.withConversionErrors(conversionErrors);
            }

            conversionErrors.put(realProperty, new ConversionData(value, toClass));
        }
    }

    public synchronized void registerConverter(String className, TypeConverter converter) {
        converterHolder.addDefaultMapping(className, converter);
    }

    public synchronized void registerConverterNotFound(String className) {
        converterHolder.addUnknownMapping(className);
    }

    private Object[] getClassProperty(Map<String, Object> context) {
        Object lastClass = context.get(LAST_BEAN_CLASS_ACCESSED);
        Object lastProperty = context.get(LAST_BEAN_PROPERTY_ACCESSED);
        return (lastClass != null && lastProperty != null) ? new Object[] {lastClass, lastProperty} : null;
    }

    /**
     * Looks for converter mappings for the specified class and adds it to an existing map.  Only new converters are
     * added.  If a converter is defined on a key that already exists, the converter is ignored.
     *
     * @param mapping an existing map to add new converter mappings to
     * @param clazz   class to look for converter mappings for
     */
    protected void addConverterMapping(Map<String, Object> mapping, Class clazz) {
        // Process <clazz>-conversion.properties file
        String converterFilename = buildConverterFilename(clazz);
        fileProcessor.process(mapping, clazz, converterFilename);

        // Process annotations
        Annotation[] annotations = clazz.getAnnotations();

        for (Annotation annotation : annotations) {
            if (annotation instanceof Conversion conversion) {
                for (TypeConversion tc : conversion.conversions()) {
                    if (mapping.containsKey(tc.key())) {
                        break;
                    }
                    if (LOG.isDebugEnabled()) {
                        if (StringUtils.isEmpty(tc.key())) {
                            LOG.debug("WARNING! key of @TypeConversion [{}/{}] applied to [{}] is empty!", tc.converter(), tc.converterClass(), clazz.getName());
                        } else {
                            LOG.debug("TypeConversion [{}/{}] with key: [{}]", tc.converter(), tc.converterClass(), tc.key());
                        }
                    }
                    annotationProcessor.process(mapping, tc, tc.key());
                }
            }
        }

        // Process annotated methods
        for (Method method : clazz.getMethods()) {
            annotations = method.getAnnotations();
            for (Annotation annotation : annotations) {
                if (annotation instanceof TypeConversion tc) {
                    String key = tc.key();
                    // Default to the property name with prefix
                    if (StringUtils.isEmpty(key)) {
                        key = AnnotationUtils.resolvePropertyName(method);
                        key = switch (tc.rule()) {
                            case COLLECTION -> DefaultObjectTypeDeterminer.DEPRECATED_ELEMENT_PREFIX + key;
                            case CREATE_IF_NULL -> DefaultObjectTypeDeterminer.CREATE_IF_NULL_PREFIX + key;
                            case ELEMENT -> DefaultObjectTypeDeterminer.ELEMENT_PREFIX + key;
                            case KEY -> DefaultObjectTypeDeterminer.KEY_PREFIX + key;
                            case KEY_PROPERTY -> DefaultObjectTypeDeterminer.KEY_PROPERTY_PREFIX + key;
                            default -> key;
                        };
                        LOG.debug("Retrieved key [{}] from method name [{}]", key, method.getName());
                    }
                    if (mapping.containsKey(key)) {
                        break;
                    }
                    annotationProcessor.process(mapping, tc, key);
                }
            }
        }
    }


    /**
     * Looks for converter mappings for the specified class, traversing up its class hierarchy and interfaces and adding
     * any additional mappings it may find.  Mappings lower in the hierarchy have priority over those higher in the
     * hierarchy.
     *
     * @param clazz the class to look for converter mappings for
     * @return the converter mappings
     * @throws Exception in case of any errors
     */
    protected Map<String, Object> buildConverterMapping(Class clazz) throws Exception {
        Map<String, Object> mapping = new HashMap<>();

        // check for conversion mapping associated with super classes and any implemented interfaces
        Class curClazz = clazz;

        while (!curClazz.equals(Object.class)) {
            // add current class' mappings
            addConverterMapping(mapping, curClazz);

            // check interfaces' mappings
            Class[] interfaces = curClazz.getInterfaces();

            for (Class anInterface : interfaces) {
                addConverterMapping(mapping, anInterface);
            }

            curClazz = curClazz.getSuperclass();
        }

        if (!mapping.isEmpty()) {
            converterHolder.addMapping(clazz, mapping);
        } else {
            converterHolder.addNoMapping(clazz);
        }

        return mapping;
    }

    private Map<String, Object> conditionalReload(Class clazz, Map<String, Object> oldValues) throws Exception {
        Map<String, Object> mapping = oldValues;

        if (reloadingConfigs) {
            URL fileUrl = ClassLoaderUtil.getResource(buildConverterFilename(clazz), clazz);
            if (fileManager.fileNeedsReloading(fileUrl)) {
                mapping = buildConverterMapping(clazz);
            }
        }

        return mapping;
    }

    /**
     * Recurses through a class' interfaces and class hierarchy looking for a TypeConverter in the default mapping that
     * can handle the specified class.
     *
     * @param clazz the class the TypeConverter must handle
     * @return a TypeConverter to handle the specified class or null if none can be found
     */
    TypeConverter lookupSuper(Class clazz) {
        TypeConverter result = null;

        if (clazz != null) {
            result = converterHolder.getDefaultMapping(clazz.getName());

            if (result == null) {
                // Looks for direct interfaces (depth = 1 )
                Class[] interfaces = clazz.getInterfaces();

                for (Class anInterface : interfaces) {
                    if (converterHolder.containsDefaultMapping(anInterface.getName())) {
                        result = converterHolder.getDefaultMapping(anInterface.getName());
                        break;
                    }
                }

                if (result == null) {
                    // Looks for the superclass
                    // If 'clazz' is the Object class, an interface, a primitive type or void then clazz.getSuperClass() returns null
                    result = lookupSuper(clazz.getSuperclass());
                }
            }
        }

        return result;
    }

}