// Copyright � 2002-2007 Canoo Engineering AG, Switzerland. package com.canoo.webtest.engine.xpath; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Vector; import java.util.Map.Entry; import javax.xml.namespace.QName; import javax.xml.transform.TransformerException; import javax.xml.xpath.XPathExpressionException; import javax.xml.xpath.XPathFunctionException; import org.apache.log4j.Logger; import org.apache.xml.dtm.DTM; import org.apache.xpath.ExtensionsProvider; import org.apache.xpath.XPathContext; import org.apache.xpath.functions.FuncExtFunction; import org.apache.xpath.functions.Function; import org.apache.xpath.functions.WrongNumberArgsException; import org.apache.xpath.jaxp.JAXPVariableStack; import org.apache.xpath.objects.XObject; import org.apache.xpath.objects.XObjectFactory; import org.w3c.dom.Node; import org.w3c.dom.traversal.NodeIterator; import com.canoo.webtest.engine.StepFailedException; import com.gargoylesoftware.htmlunit.Page; import com.gargoylesoftware.htmlunit.html.HtmlPage; import com.gargoylesoftware.htmlunit.html.xpath.XPathUtils; import com.gargoylesoftware.htmlunit.xml.XmlPage; /** * Helper class for a central XPath creation allowing to share variable, * function and namespace contexts. * TODO: cleanup to use plainly javax.xml.xpath.* * @author Marc Guillemot */ public class XPathHelper { private static final Logger LOG = Logger.getLogger(XPathHelper.class); private SimpleXPathVariableResolver fVariableContext = new SimpleXPathVariableResolver(); private SimpleXPathFunctionResolver fFunctionContext = new SimpleXPathFunctionResolver(); private SimpleNamespaceContext fNamespaceContext = new SimpleNamespaceContext(); // hack for a problem in HtmlUnit-2.4 (see XPathHelperTest#upperCaseHtmlTags for details) final ThreadLocal<Boolean> htmlUnitXPathUtil_XPathProcessingFlag_; private static Map<QName, Object> sGlobalVariables = Collections .synchronizedMap(new HashMap<QName, Object>()); private static Map<QName, Class<? extends Function>> sGlobalFunctions = Collections .synchronizedMap(new HashMap<QName, Class<? extends Function>>()); private static Map<String, String> sGlobalNamespaces = Collections .synchronizedMap(new HashMap<String, String>()); static { registerWebTestGoodies(); } /** * Registers a global variable. Global variables are added to the list of * variable of a webtest at the webtest start. * @param namespaceURI the namespace URI of the function to be registered (<code>null</code> * if none). * @param localName the non-prefixed local portion of the function name to * be registered * @param value the variable to be registered */ public static void registerGlobalVariable(final String namespaceURI, final String localName, final Object value) { sGlobalVariables.put(new QName(namespaceURI, localName), value); } /** * Registers WebTest XPath extras. */ private static void registerWebTestGoodies() { final String namespaceURI = "http://webtest.canoo.com"; registerGlobalNamespace("wt", namespaceURI); registerGlobalFunction(namespaceURI, "matches", MatchesFunction.class); registerGlobalFunction(namespaceURI, "cleanText", CleanTextFunction.class); } /** * Gets the registered global variables. * @return a synchronized map of (MemberKey, variable value) */ public static Map<QName, Object> getGlobalVariables() { return sGlobalVariables; } /** * Gets the registered global functions. * @return a synchronized map of (MemberKey, function) */ public static Map<QName, Class<? extends Function>> getGlobalFunctions() { return sGlobalFunctions; } /** * Gets the registered global namespaces. * @return a synchronized map of (prefix, namespaceURI) */ public static Map<String, String> getGlobalNamespaces() { return sGlobalNamespaces; } /** * Registers a global function. Global functions are added to the list of * functions of a webtest at the webtest start. * @param namespaceURI the namespace URI of the function to be registered (<code>null</code> * if none). * @param localName the non-prefixed local portion of the function name to * be registered * @param function the function to be registered */ public static void registerGlobalFunction(final String namespaceURI, final String localName, final Class<? extends Function> function) { sGlobalFunctions.put(new QName(namespaceURI, localName), function); } /** * Registers a global namespace. Global namespaces are added to the list of * namespaces of a webtest at the webtest start. * @param prefix the namespace prefix to resolve * @param namespaceURI the namespace URI */ public static void registerGlobalNamespace(final String prefix, final String namespaceURI) { sGlobalNamespaces.put(prefix, namespaceURI); } /** * Initializes from the global functions, variables and namespaces. */ public XPathHelper() { // copy global namespaces for (final Entry<String, String> entry : getGlobalNamespaces().entrySet()) { getNamespaceContext().addNamespace(entry.getKey(), entry.getValue()); } // copy global functions for (final Entry<QName, Class<? extends Function>> entry : getGlobalFunctions().entrySet()) { final QName memberKey = entry.getKey(); getFunctionContext().registerFunction(memberKey, entry.getValue()); } // copy global variables for (final Entry<QName, Object> entry : getGlobalVariables().entrySet()) { final QName memberKey = entry.getKey(); getVariableContext().setVariableValue(memberKey, entry.getValue()); } Field f; try { f = XPathUtils.class.getDeclaredField("PROCESS_XPATH_"); f.setAccessible(true); htmlUnitXPathUtil_XPathProcessingFlag_ = (ThreadLocal<Boolean>) f.get(XPathUtils.class); } catch (final Exception e) { throw new RuntimeException("Failed to hack HtmlUnit-2.4 XPathUtils.PROCESS_XPATH_", e); } } /** * Gets the function resolver used during XPath evaluation for this webtest. * @return the context */ public SimpleXPathFunctionResolver getFunctionContext() { return fFunctionContext; } /** * Gets the namespace context used for namespace resolution during XPath * evaluation for this webtest. * @return the context */ public SimpleNamespaceContext getNamespaceContext() { return fNamespaceContext; } /** * Gets the variable context used for variable resolution (the $foo in an * xpath expression) during XPath evaluation for this webtest. * @return the context */ public SimpleXPathVariableResolver getVariableContext() { return fVariableContext; } /** * Gets the document object associated to the page that could be provided to * the XPath for computations * @param page the page * @return the "document" */ protected Object getDocument(final Page page) { if (page == null) return null; // no page, only xpath not refering to the document // tree should work else if (page instanceof HtmlPage) return page; else if (page instanceof XmlPage) { final XmlPage xmlPage = (XmlPage) page; if (xmlPage.getXmlDocument() == null) // when content type was xml // but document couldn't be // parsed throw new StepFailedException( "The xml document couldn't be parsed as it is not well formed"); return xmlPage.getXmlDocument(); } else { throw buildInvalidDocumentException(page); } } /** * Utility to build exception for invalid page * @param page the page * @return the exception to throw */ StepFailedException buildInvalidDocumentException(final Page page) { return new StepFailedException( "Current response is not an HTML or XML page but of type " + page.getWebResponse().getContentType() + " (" + page.getClass().getName() + ")"); } public String stringValueOf(final Page _page, final String _xpath) throws XPathExpressionException { try { htmlUnitXPathUtil_XPathProcessingFlag_.set(true); final XObject result = eval(_xpath, getDocument(_page)); return result.str(); } catch (final TransformerException e) { throw handleException(e); } finally { htmlUnitXPathUtil_XPathProcessingFlag_.set(false); } } private XPathExpressionException handleException(TransformerException _e) { final Throwable nestedException = _e.getException(); if (nestedException instanceof XPathFunctionException) { return (XPathFunctionException) nestedException; } else { // if (true) // throw new RuntimeException(_e.getCause()); LOG.info("XPath error", _e); return new XPathExpressionException(_e.getMessage()); // stupid but XPathExpressionException(e).getMessage() is null! } } public List<? extends Object> selectNodes(final Page _page, final String _xpath) throws XPathExpressionException { return (List<? extends Object>) getByXPath(_page, _xpath, false); } public Object selectFirst(final Page _page, final String _xpath) throws XPathExpressionException { return getByXPath(_page, _xpath, true); } protected Object getByXPath(final Page _currentResp, final String _xpath, final boolean _onlyFirstResult) throws XPathExpressionException { try { htmlUnitXPathUtil_XPathProcessingFlag_.set(true); final XObject result = eval(_xpath, getDocument(_currentResp)); switch (result.getType()) { case XObject.CLASS_BOOLEAN: return result.bool(); case XObject.CLASS_NUMBER: return result.num(); case XObject.CLASS_STRING: return result.str(); case XObject.CLASS_NODESET: if (_onlyFirstResult) return result.nodeset().nextNode(); else return toList(result.nodeset()); default: throw new RuntimeException("Unexpected result type for >" + _xpath + "<: " + result.getType()); } } catch (final TransformerException e) { throw handleException(e); } finally { htmlUnitXPathUtil_XPathProcessingFlag_.set(false); } } private List<Node> toList(final NodeIterator _nodeset) { final List<Node> result = new ArrayList<Node>(); Node node = _nodeset.nextNode(); while (node != null) { result.add(node); node = _nodeset.nextNode(); } // TODO Auto-generated method stub return result; } private XObject eval(final String expression, final Object contextItem) throws javax.xml.transform.TransformerException { final PrefixResolver prefixResolver = new PrefixResolver(fNamespaceContext, contextItem); org.apache.xpath.XPath xpath = new org.apache.xpath.XPath(expression, null, prefixResolver, org.apache.xpath.XPath.SELECT, null, fFunctionContext.getFunctionTable()); // function resolver final XPathContext[] contexts = {null}; final ExtensionsProvider extProvider = new ExtensionsProvider() { public boolean elementAvailable(String _ns, String _elemName) throws TransformerException { return false; } public Object extFunction(final FuncExtFunction _extFunction, Vector _argVec) throws TransformerException { final String ns = _extFunction.getNamespace(); final String name = _extFunction.getFunctionName(); final Function func = fFunctionContext.resolveFunction(new QName(ns, name), 0); if (func == null) throw new RuntimeException("Can't find function " + name + " (namespace: " + ns + ")"); for (int i=0; i<_argVec.size(); ++i) { try { func.setArg(XObjectFactory.create(_argVec.get(i)), i); } catch (final WrongNumberArgsException e) { throw new RuntimeException(e); } } return func.execute(contexts[0]); } public Object extFunction(String _ns, String _funcName, Vector _argVec, Object _methodKey) throws TransformerException { return null; } public boolean functionAvailable(String _ns, String _funcName) throws TransformerException { return fFunctionContext.resolveFunction(new QName(_ns, _funcName), 0) != null; } }; XPathContext xpathSupport = new XPathContext(extProvider); contexts[0] = xpathSupport; xpathSupport.setVarStack(new JAXPVariableStack(fVariableContext)); // If item is null, then we will create a a Dummy contextNode final XObject xobj; if (contextItem instanceof Node) { xobj = xpath.execute(xpathSupport, (Node) contextItem, prefixResolver); } else { xobj = xpath.execute(xpathSupport, DTM.NULL, prefixResolver); } return xobj; } /** * Quotes the provided value, handling quotes and double quotes if needed to * @param value the value to quote * @return the quoted value usable in XPath expression */ public static String quote(final String value) { if (!value.contains("'")) { return "'" + value + "'"; } else if (!value.contains("\"")) { return "\"" + value + "\""; } else { final String[] parts = value.split("'"); String response = "concat("; for (int i=0; i<parts.length-1; ++i) { response += "'" + parts[i] + "', \"'\", "; } response += "'" + parts[parts.length-1] + "')"; return response; } } }