All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.xmlbeam.ProjectionInvocationHandler Maven / Gradle / Ivy

/**
 *  Copyright 2012 Sven Ewald
 *
 *  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.
 */
package org.xmlbeam;

import java.io.IOException;
import java.io.Serializable;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.text.MessageFormat;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

import javax.xml.parsers.ParserConfigurationException;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;

import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import org.xmlbeam.XBProjector.InternalProjection;
import org.xmlbeam.annotation.XBDelete;
import org.xmlbeam.annotation.XBDocURL;
import org.xmlbeam.annotation.XBRead;
import org.xmlbeam.annotation.XBValue;
import org.xmlbeam.annotation.XBWrite;
import org.xmlbeam.dom.DOMAccess;
import org.xmlbeam.types.TypeConverter;
import org.xmlbeam.util.intern.ASMHelper;
import org.xmlbeam.util.intern.DOMHelper;
import org.xmlbeam.util.intern.MethodParamVariableResolver;
import org.xmlbeam.util.intern.ReflectionHelper;

/**
 * This class implements the "magic" behind projection methods. Each projection is linked with a
 * ProjectionInvocatonHandler which handles method invocations on the projections. Notice that this
 * class is not part of the public API. You should not get in touch with this class at all. See
 * {@link org.xmlbeam.XBProjector} for API usage.
 *
 * @author Sven Ewald
 */
@SuppressWarnings("serial")
final class ProjectionInvocationHandler implements InvocationHandler, Serializable {
    private final static String NONEMPTY = "(?!^$)";
    private final static String XML_NAME_START_CHARS=":A-Z_a-z\\u00C0\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02ff\\u0370-\\u037d"
                                                  + "\\u037f-\\u1fff\\u200c\\u200d\\u2070-\\u218f\\u2c00-\\u2fef\\u3001-\\ud7ff"
                                                  + "\\uf900-\\ufdcf\\ufdf0-\\ufffd"+String.valueOf(Character.toChars(0x10000))+"-"+String.valueOf(Character.toChars(0xEFFFF));     
    private final static String XML_NAME_CHARS=XML_NAME_START_CHARS+"\\-\\.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040";
    private final static String XML_ELEMENT = "["+XML_NAME_START_CHARS+"]"+"["+XML_NAME_CHARS+"]*";
    private final static String ELEMENT_PATH = "(/"+XML_ELEMENT+"(\\[@?"+XML_ELEMENT+"='.+'\\])?)";
    private final static String ATTRIBUTE_PATH = "(/?@"+XML_ELEMENT+")";
    private final static String PARENT_PATH = "(/\\.\\.)";
    private static final Pattern LEGAL_XPATH_SELECTORS_FOR_SETTERS = Pattern.compile(NONEMPTY + "(^\\.?(" + ELEMENT_PATH + "*" + PARENT_PATH + "*)*(" + ATTRIBUTE_PATH + "|(/\\*))?$)");
    private final Node node;
    private final Class projectionInterface;
    private final XBProjector projector;

    // Used to handle invocations on Java6 Mixins and Object methods.
    private final Map, Object> defaultInvokers;

    // Used to handle invocations on Java8 default methods.
    private transient Object defaultMethodInvoker;

    ProjectionInvocationHandler(final XBProjector projector, final Node node, final Class projectionInterface, final Map, Object> defaultInvokers) {
        this.projector = projector;
        this.node = node;
        this.projectionInterface = projectionInterface;
        this.defaultInvokers = defaultInvokers;
    }

    /**
     * @param collection
     * @param parentElement
     * @param elementSelector
     */
    private void applyCollectionSetOnElement(final Collection collection, final Element parentElement, final String elementSelector) {
        final Document document = parentElement.getOwnerDocument();
        DOMHelper.removeAllChildrenBySelector(parentElement, elementSelector);
        assert !elementSelector.contains("/") : "Selector should be the trail of the path.";
        final String elementName = elementSelector.replaceAll("\\[.*", "");
        if (collection == null) {
            return;
        }
        for (Object o : collection) {
            if (o == null) {
                continue;
            }
            if (!(o instanceof InternalProjection)) {
                final Element newElement = document.createElement(elementName);
                newElement.setTextContent(o.toString());
                parentElement.appendChild(newElement);
                continue;
            }
            final InternalProjection p = (InternalProjection) o;
            Element pElement = Node.DOCUMENT_NODE == p.getDOMNode().getNodeType() ? p.getDOMOwnerDocument().getDocumentElement() : (Element) p.getDOMNode();
            if (pElement == null) {
                continue;
            }
            Element clone = (Element) pElement.cloneNode(true);
            if (!elementName.equals(clone.getNodeName())) {
                if (!"*".equals(elementName)) {
                    clone = DOMHelper.renameElement(clone, elementName);
                }
            }
            DOMHelper.ensureOwnership(document, clone);
            parentElement.appendChild(clone);
        }
    }

    private void applySingleSetProjectionOnElement(final InternalProjection projection, final Node parentNode, final String elementSelector) {
        final Element newElement = (Element) projection.getDOMBaseElement().cloneNode(true);
        DOMHelper.removeAllChildrenBySelector(parentNode, elementSelector);
        DOMHelper.ensureOwnership(parentNode.getOwnerDocument(), newElement);
        parentNode.appendChild(newElement);
    }

    private List evaluateAsList(final XPathExpression expression, final Node node, final Method method) throws XPathExpressionException {
        final NodeList nodes = (NodeList) expression.evaluate(node, XPathConstants.NODESET);
        final List linkedList = new LinkedList();
        final Class targetType = findTargetComponentType(method);
        final TypeConverter typeConverter = projector.config().getTypeConverter();
        if (typeConverter.isConvertable(targetType)) {
            for (int i = 0; i < nodes.getLength(); ++i) {
                linkedList.add(typeConverter.convertTo(targetType, nodes.item(i).getTextContent()));
            }
            return linkedList;
        }
        if (Node.class.equals(targetType)) {
            for (int i = 0; i < nodes.getLength(); ++i) {
                linkedList.add(nodes.item(i));
            }
            return linkedList;
        }
        if (targetType.isInterface()) {
            for (int i = 0; i < nodes.getLength(); ++i) {
                InternalProjection subprojection = (InternalProjection) projector.projectDOMNode(nodes.item(i), targetType);
                linkedList.add(subprojection);
            }
            return linkedList;
        }
        throw new IllegalArgumentException("Return type " + targetType + " is not valid for list or array component type returning from method " + method + " using the current type converter:" + projector.config().getTypeConverter()
                + ". Please change the return type to a sub projection or add a conversion to the type converter.");
    }

    /**
     * Setter projection methods may have multiple parameters. One of them may be annotated with
     * {@link XBValue} to select it as value to be set.
     *
     * @param method
     * @return index of fist parameter annotated with {@link XBValue} annotation.
     */
    private int findIndexOfValue(final Method method) {
        int index = 0;
        for (Annotation[] annotations : method.getParameterAnnotations()) {
            for (Annotation a : annotations) {
                if (XBValue.class.equals(a.annotationType())) {
                    return index;
                }
            }
            ++index;
        }
        return 0; // If no attribute is annotated, the first one is taken.
    }

    /**
     * When reading collections, determine the collection component type.
     *
     * @param method
     * @return
     */
    private Class findTargetComponentType(final Method method) {
        if (method.getReturnType().isArray()) {
            return method.getReturnType().getComponentType();
        }
        assert method.getAnnotation(XBRead.class) != null;

        final Class targetType = determineTargetTypeForList(method);
        if (XBRead.class.equals(targetType)) {
            throw new IllegalArgumentException("When using List as return type for method " + method + ", please specify the list content type in the " + XBRead.class.getSimpleName() + " annotaion. I can not determine it from the method signature.");
        }
        return targetType;
    }

    /**
     * Extract the generic type of the List which currently is our return type.
     *
     * @param method
     * @return component type of List to be created.
     */
    private Class determineTargetTypeForList(final Method method) {
        assert List.class.equals(method.getReturnType());
        final Type type = method.getGenericReturnType();
        if (!(type instanceof ParameterizedType) || (((ParameterizedType) type).getActualTypeArguments() == null) || (((ParameterizedType) type).getActualTypeArguments().length < 1)) {
            throw new IllegalArgumentException("When using List as return type for method " + method + ", please specify a generic type for the List. Otherwise I do not know which type I should fill the List with.");
        }
        assert ((ParameterizedType) type).getActualTypeArguments().length == 1 : "";
        return (Class) ((ParameterizedType) type).getActualTypeArguments()[0];
    }

    private Node getNodeForMethod(final Method method, final Object[] args) throws SAXException, IOException, ParserConfigurationException {
        final XBDocURL docURL = method.getAnnotation(XBDocURL.class);
        if (docURL != null) {
            String uri = projector.config().getExternalizer().resolveURL(docURL.value(), method, args);
            final Map requestParams = projector.io().filterRequestParamsFromParams(uri, args);
            uri = applyParams(uri, method, args);
            return DOMHelper.getDocumentFromURL(projector.config().createDocumentBuilder(), uri, requestParams, projectionInterface);
        }
        return node;
    }

    /**
     * @param uri
     * @param method
     * @param args
     * @return a string with all place holders filled by given parameters
     */
    private String applyParams(String string, Method method, Object[] args) {
        int c = 0;
        for (String param : ReflectionHelper.getMethodParameterNames(method)) {
            string = string.replace("{" + param + "}", "" + args[c++]);
        }
        return MessageFormat.format(string, args);
    }

    /**
     * Determine a methods return value that does not depend on the methods execution. Possible
     * values are void or the proxy itself (would be "this").
     *
     * @param method
     * @return
     */
    private Object getProxyReturnValueForMethod(final Object proxy, final Method method) {
        if (!ReflectionHelper.hasReturnType(method)) {
            return null;
        }
        if (method.getReturnType().equals(method.getDeclaringClass())) {
            return proxy;
        }
        throw new IllegalArgumentException("Method " + method + " has illegal return type \"" + method.getReturnType() + "\". I don't know what to return. I expected void or " + method.getDeclaringClass().getSimpleName());
    }

    /**
     * Find the "me" attribute (which is a replacement for "this") and inject the projection proxy
     * instance.
     *
     * @param me
     * @param target
     */
    private void injectMeAttribute(final InternalProjection me, final Object target) {
        //final Class projectionInterface = me.getProjectionInterface();
        for (Field field : target.getClass().getDeclaredFields()) {
            if (!isValidMeField(field, projectionInterface)) {
                continue;
            }
            if (!field.isAccessible()) {
                field.setAccessible(true);
            }
            try {
                field.set(target, me);
                return;
            } catch (IllegalAccessException e) {
                throw new RuntimeException(e);
            }
        }
        throw new IllegalArgumentException("Mixin " + target.getClass().getSimpleName() + " needs an attribute \"private " + projectionInterface.getSimpleName() + " me;\" to be able to access the projection.");
    }

    private boolean isValidMeField(final Field field, final Class projInterface) {
        if (field == null) {
            return false;
        }
        if (!"me".equalsIgnoreCase(field.getName())) {
            return false;
        }
        if (DOMAccess.class.equals(field.getType())) {
            return true;
        }
        return field.getType().isAssignableFrom(projInterface);
    }

    @Override
    public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable {
        {
            String resolvedXpath = null;
            try {
                final XBRead readAnnotation = method.getAnnotation(XBRead.class);
                if (readAnnotation != null) {
                    resolvedXpath = applyParams(projector.config().getExternalizer().resolveXPath(readAnnotation.value(), method, args), method, args);
                    return invokeGetter(proxy, method, resolvedXpath, args);
                }

                final XBWrite writeAnnotation = method.getAnnotation(XBWrite.class);
                if (writeAnnotation != null) {
                    resolvedXpath = applyParams(projector.config().getExternalizer().resolveXPath(writeAnnotation.value(), method, args), method, args);
                    return invokeSetter(proxy, method, resolvedXpath, args);
                }

                final XBDelete delAnnotation = method.getAnnotation(XBDelete.class);
                if (delAnnotation != null) {
                    resolvedXpath = applyParams(projector.config().getExternalizer().resolveXPath(delAnnotation.value(), method, args), method, args);
                    return invokeDeleter(proxy, method, resolvedXpath, args);
                }
            } catch (XPathExpressionException e) {
                throw new XBPathException(e, method, resolvedXpath);
            }
        }
        final Class methodsDeclaringInterface = ReflectionHelper.findDeclaringInterface(method, projectionInterface);
        final Object customInvoker = projector.mixins().getProjectionMixin(projectionInterface, methodsDeclaringInterface);

        if (customInvoker != null) {
            injectMeAttribute((InternalProjection) proxy, customInvoker);
            return method.invoke(customInvoker, args);
        }

        final Object defaultInvoker = defaultInvokers.get(methodsDeclaringInterface);
        if (defaultInvoker != null) {
            return method.invoke(defaultInvoker, args);
        }

        if (ReflectionHelper.isDefaultMethod(method)) {
            if (defaultMethodInvoker == null) {
                defaultMethodInvoker = ASMHelper.createDefaultMethodProxy(projectionInterface, proxy);
            }
            try {
                return method.invoke(defaultMethodInvoker, args);
            } catch (InvocationTargetException e) {
                if (e.getCause() != null) {
                    throw e.getCause();
                }
                throw e;
            }
        }
        throw new IllegalArgumentException("I don't known how to invoke method " + method + ". Did you forget to add a XB*-annotation or to register a mixin?");
    }

    /**
     * @param proxy
     * @param format
     */
    private Object invokeDeleter(final Object proxy, final Method method, final String path, final Object[] args) throws Throwable {
        final Document document = DOMHelper.getOwnerDocumentFor(node);
        final XPath xPath = projector.config().createXPath(document);
        try {
            if (ReflectionHelper.mayProvideParameterNames()) {
                xPath.setXPathVariableResolver(new MethodParamVariableResolver(method, args, xPath.getXPathVariableResolver()));
            }

            final XPathExpression expression = xPath.compile(path);
            NodeList nodes = (NodeList) expression.evaluate(node, XPathConstants.NODESET);
            for (int i = 0; i < nodes.getLength(); ++i) {
                if (Node.ATTRIBUTE_NODE == nodes.item(i).getNodeType()) {
                    Attr attr = (Attr) nodes.item(i);
                    attr.getOwnerElement().removeAttributeNode(attr);
                    continue;
                }
                Node parentNode = nodes.item(i).getParentNode();
                if (parentNode == null) {
                    continue;
                }
                parentNode.removeChild(nodes.item(i));
            }
            return getProxyReturnValueForMethod(proxy, method);
        } finally {
            xPath.reset();
        }
    }

    private Object invokeGetter(final Object proxy, final Method method, final String path, final Object[] args) throws Throwable {
        final Node node = getNodeForMethod(method, args);
        final Document document = DOMHelper.getOwnerDocumentFor(node);
        final XPath xPath = projector.config().createXPath(document);
// Automatic propagation of parameters as XPath variables
// disabled so far...        
//        try {
//        if (ReflectionHelper.mayProvideParameterNames()) {
//            xPath.setXPathVariableResolver(new MethodParamVariableResolver(method,args,xPath.getXPathVariableResolver()));            
//        }
        final XPathExpression expression = xPath.compile(path);
        final Class returnType = method.getReturnType();
        if (projector.config().getTypeConverter().isConvertable(returnType)) {
            String data = (String) expression.evaluate(node, XPathConstants.STRING);
            try {
                return projector.config().getTypeConverter().convertTo(returnType, data);
            } catch (NumberFormatException e) {
                throw new NumberFormatException(e.getMessage() + " XPath was:" + path);
            }
        }
        if (Node.class.equals(returnType)) {
            return expression.evaluate(node, XPathConstants.NODE);
        }

        if (List.class.equals(returnType)) {
            return evaluateAsList(expression, node, method);
        }
        if (returnType.isArray()) {
            List list = evaluateAsList(expression, node, method);
            return list.toArray((Object[]) java.lang.reflect.Array.newInstance(returnType.getComponentType(), list.size()));
        }
        if (returnType.isInterface()) {
            Node newNode = (Node) expression.evaluate(node, XPathConstants.NODE);
            if (newNode == null) {
                return null;
            }
            InternalProjection subprojection = (InternalProjection) projector.projectDOMNode(newNode, returnType);
            return subprojection;
        }
        throw new IllegalArgumentException("Return type " + returnType + " of method " + method + " is not supported. Please change to an projection interface, a List, an Array or one of current type converters types:" + projector.config().getTypeConverter());
// Automatic propagation of parameters as XPath variables
// disabled so far...
//        } finally {
//            xPath.reset();
//        }
    }

    private Object invokeSetter(final Object proxy, final Method method, final String path, final Object[] args) throws Throwable {
        if (!LEGAL_XPATH_SELECTORS_FOR_SETTERS.matcher(path).matches()) {
            throw new IllegalArgumentException("Method " + method + " was invoked as setter and did not have an XPATH expression with an absolute path to an element or attribute:\"" + path + "\"");
        }
        if (!ReflectionHelper.hasParameters(method)) {
            throw new IllegalArgumentException("Method " + method + " was invoked as setter but has no parameter. Please add a parameter so this method could actually change the DOM.");
        }
        if (method.getAnnotation(XBDocURL.class) != null) {
            throw new IllegalArgumentException("Method " + method + " was invoked as setter but has a @" + XBDocURL.class.getSimpleName() + " annotation. Defining setters on external projections is not valid because there is no DOM attached.");
        }
        final String pathToElement = path.replaceAll("\\[@", "[attribute::").replaceAll("/?@.*", "").replaceAll("\\[attribute::", "[@");
        final Node settingNode = getNodeForMethod(method, args);
        final Document document = DOMHelper.getOwnerDocumentFor(settingNode);
        assert document != null;
        final int findIndexOfValue = findIndexOfValue(method);
        final Object valueToSet = args[findIndexOfValue];
        final Class typeToSet = method.getParameterTypes()[findIndexOfValue];
        final boolean isMultiValue = isMultiValue(typeToSet);

        if ("/*".equals(pathToElement)) { // Setting a new root element.
            if (isMultiValue) {
                throw new IllegalArgumentException("Method " + method + " was invoked as setter changing the document root element, but tries to set multiple values.");
            }
            if (valueToSet == null) {
                DOMHelper.setDocumentElement(document, null);
                return getProxyReturnValueForMethod(proxy, method);
            }
            if (!(valueToSet instanceof InternalProjection)) {
                throw new IllegalArgumentException("Method " + method + " was invoked as setter changing the document root element. Expected value type was a projection so I can determine a element name. But you provided a " + valueToSet);
            }
            InternalProjection projection = (InternalProjection) valueToSet;
            Element element = projection.getDOMBaseElement();
            assert element != null;
            DOMHelper.setDocumentElement(document, element);
            return getProxyReturnValueForMethod(proxy, method);
        }

        if (isMultiValue) {
            if (path.contains("@")) {
                throw new IllegalArgumentException("Method " + method + " was invoked as setter changing some attribute, but was declared to set multiple values. I can not create multiple attributes for one path.");
            }
            final String path2Parent = pathToElement.replaceAll("/[^/]+$", "");
            final String elementSelector = pathToElement.replaceAll(".*/", "");
            final Element parentElement = DOMHelper.ensureElementExists(document, path2Parent);
            //   DOMHelper.removeAllChildrenBySelector(parentElement, elementSelector);
//            if (valueToSet == null) {
//                return getProxyReturnValueForMethod(proxy, method);
//            }
            Collection collection2Set = (valueToSet != null) && (valueToSet.getClass().isArray()) ? ReflectionHelper.array2ObjectList(valueToSet) : (Collection) valueToSet;
            applyCollectionSetOnElement(collection2Set, parentElement, elementSelector);
            return getProxyReturnValueForMethod(proxy, method);
        }

        if (valueToSet instanceof InternalProjection) {
            String pathToParent = pathToElement.replaceAll("/[^/]*$", "");
            String elementSelector = pathToElement.replaceAll(".*/", "");
            Element parentNode = DOMHelper.ensureElementExists(document, pathToParent);
            applySingleSetProjectionOnElement((InternalProjection) valueToSet, parentNode, elementSelector);
            return getProxyReturnValueForMethod(proxy, method);
        }

        Element elementToChange;
        if (node.getNodeType() == Node.DOCUMENT_NODE) {
            elementToChange = DOMHelper.ensureElementExists(document, pathToElement);
        } else {
            assert node.getNodeType() == Node.ELEMENT_NODE;
            elementToChange = DOMHelper.ensureElementExists(document, (Element) node, pathToElement);
        }

        if (path.replaceAll("\\[@", "[attribute::").contains("@")) {
            String attributeName = path.replaceAll(".*@", "");
            DOMHelper.setOrRemoveAttribute(elementToChange, attributeName, valueToSet == null ? null : valueToSet.toString());
            return getProxyReturnValueForMethod(proxy, method);
        }
        if (valueToSet == null) {
            DOMHelper.removeAllChildrenBySelector(elementToChange, "*");
        } else {
            elementToChange.setTextContent(valueToSet.toString());
        }
        return getProxyReturnValueForMethod(proxy, method);
    }

    /**
     * @param typeToSet
     * @return
     */
    private boolean isMultiValue(final Class type) {
        return type.isArray() || Collection.class.isAssignableFrom(type);
    }

}