/* * Copyright 2014 JBoss Inc * * 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 io.apiman.test.common.util; import io.apiman.test.common.json.JsonArrayOrderingType; import io.apiman.test.common.json.JsonCompare; import io.apiman.test.common.json.JsonMissingFieldType; import io.apiman.test.common.plan.TestGroupType; import io.apiman.test.common.plan.TestPlan; import io.apiman.test.common.plan.TestType; import io.apiman.test.common.resttest.RestTest; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.StringWriter; import java.net.ProtocolException; import java.net.URI; import java.net.URISyntaxException; import java.text.MessageFormat; import java.util.List; import java.util.Map; import java.util.Map.Entry; import org.apache.commons.codec.binary.Base64; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.custommonkey.xmlunit.Diff; import org.custommonkey.xmlunit.Difference; import org.custommonkey.xmlunit.DifferenceListener; import org.custommonkey.xmlunit.ElementNameQualifier; import org.custommonkey.xmlunit.XMLAssert; import org.custommonkey.xmlunit.XMLUnit; import org.junit.Assert; import org.mvel2.MVEL; import org.mvel2.integration.PropertyHandler; import org.mvel2.integration.PropertyHandlerFactory; import org.mvel2.integration.VariableResolverFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Element; import org.w3c.dom.Node; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.jcabi.http.Request; import com.jcabi.http.Response; import com.jcabi.http.request.ApacheRequest; /** * Runs a test plan. * * @author eric.wittmann@redhat.com */ @SuppressWarnings({ "nls", "javadoc" }) public class TestPlanRunner { private static Logger logger = LoggerFactory.getLogger(TestPlanRunner.class); /** * Constructor. */ public TestPlanRunner() { } /** * Called to run a test plan. * * @param resourcePath * @param cl * @param baseApiUrl */ public void runTestPlan(String resourcePath, ClassLoader cl, String baseApiUrl) { TestPlan testPlan = TestUtil.loadTestPlan(resourcePath, cl); log(""); log("-------------------------------------------------------------------------------"); log("Executing Test Plan: " + resourcePath); log(" Base API URL: " + baseApiUrl); log("-------------------------------------------------------------------------------"); log(""); for (TestGroupType group : testPlan.getTestGroup()) { log("-----------------------------------------------------------"); log("Starting Test Group [{0}]", group.getName()); log("-----------------------------------------------------------"); for (TestType test : group.getTest()) { String rtPath = test.getValue(); Integer delay = test.getDelay(); log("Executing REST Test [{0}] - {1}", test.getName(), rtPath); if (delay != null) { try { Thread.sleep(delay); } catch (InterruptedException e) { } } if (rtPath == null || rtPath.trim().isEmpty()) { continue; } RestTest restTest = TestUtil.loadRestTest(rtPath, cl); runTest(restTest, baseApiUrl); log("REST Test Completed"); log("+++++++++++++++++++"); } log("Test Group [{0}] Completed Successfully", group.getName()); } log(""); log("-------------------------------------------------------------------------------"); log("Test Plan successfully executed: " + resourcePath); log("-------------------------------------------------------------------------------"); log(""); } /** * Runs a single REST test. * * @param restTest * @param baseApiUrl * @throws Error */ public void runTest(RestTest restTest, String baseApiUrl) throws Error { String requestPath = TestUtil.doPropertyReplacement(restTest.getRequestPath()); URI uri = null; try { uri = getUri(baseApiUrl, requestPath); } catch (URISyntaxException e) { throw new RuntimeException("Invalid URI", e); } log("Sending HTTP request to: " + uri); Request request = new ApacheRequest(uri.toString()).method(restTest.getRequestMethod()); try { Map<String, String> requestHeaders = restTest.getRequestHeaders(); for (Entry<String, String> entry : requestHeaders.entrySet()) { String value = TestUtil.doPropertyReplacement(entry.getValue()); // Handle system properties that may be configured in the rest-test itself if (entry.getKey().startsWith("X-RestTest-System-Property")) { String[] split = value.split("="); System.setProperty(split[0], split[1]); continue; } if (entry.getKey().equals("Content-Type")) { String contentType = entry.getKey() != null ? StringUtils.appendIfMissing(value, "; charset=UTF-8") : "text/plain; charset=UTF-8"; request = request.header(entry.getKey(), contentType); } else { request = request.header(entry.getKey(), value); } } // Set up basic auth String authorization = createBasicAuthorization(restTest.getUsername(), restTest.getPassword()); if (authorization != null) { request = request.header("Authorization", authorization); } if (restTest.getRequestPayload() != null && !restTest.getRequestPayload().isEmpty()) { request = request.body().set(restTest.getRequestPayload()).back(); } assertResponse(restTest, request.fetch()); } catch (Error e) { logPlain("[ERROR] " + e.getMessage()); throw e; } catch (ProtocolException e) { logPlain("[HTTP PROTOCOL EXCEPTION] " + e.getMessage()); throw new Error(e); } catch (IOException e) { logPlain("[IO EXCEPTION] " + e.getMessage()); throw new Error(e); } catch (Exception e) { logPlain("[EXCEPTION] " + e.getMessage()); throw new Error(e); } } /** * Create the basic auth header value. * * @param username * @param password */ private String createBasicAuthorization(String username, String password) { if (username == null || username.trim().length() == 0) { return null; } username = TestUtil.doPropertyReplacement(username); password = TestUtil.doPropertyReplacement(password); String val = username + ":" + password; return "Basic " + Base64.encodeBase64String(val.getBytes()).trim(); } /** * Assert that the response matched the expected. * * @param restTest * @param response */ private void assertResponse(RestTest restTest, Response response) { int actualStatusCode = response.status(); try { Assert.assertEquals("Unexpected REST response status code. Status message: " + response.reason(), restTest.getExpectedStatusCode(), actualStatusCode); } catch (Error e) { if (actualStatusCode >= 400) { InputStream content = null; try { String payload = response.body(); System.out.println("------ START ERROR PAYLOAD ------"); if (payload.startsWith("{")) { payload = payload.replace("\\r\\n", "\r\n").replace("\\t", "\t"); } System.out.println(payload); System.out.println("------ END ERROR PAYLOAD ------"); } catch (Exception e1) { } finally { IOUtils.closeQuietly(content); } } throw e; } for (Entry<String, String> entry : restTest.getExpectedResponseHeaders().entrySet()) { String expectedHeaderName = entry.getKey(); if (expectedHeaderName.startsWith("X-RestTest-")) continue; String expectedHeaderValue = entry.getValue(); List<String> headers = response.headers().get(expectedHeaderName); Assert.assertNotNull("Expected header to exist but was not found: " + expectedHeaderName, headers); Assert.assertEquals(expectedHeaderValue, headers.get(0)); } List<String> ctValueList = response.headers().get("Content-Type"); if (ctValueList == null) { assertNoPayload(restTest, response); } else { String ctValueFirst = ctValueList.get(0); if (ctValueFirst.startsWith("application/json")) { assertJsonPayload(restTest, response); } else if (ctValueFirst.startsWith("text/plain") || ctValueFirst.startsWith("text/html")) { assertTextPayload(restTest, response); } else if (ctValueFirst.startsWith("application/xml") || ctValueFirst.startsWith("application/wsdl+xml")) { assertXmlPayload(restTest, response); } else { Assert.fail("Unsupported response payload type: " + ctValueFirst); } } } /** * Asserts that the response has no payload and that we are not expecting one. * * @param restTest * @param response */ private void assertNoPayload(RestTest restTest, Response response) { String expectedPayload = restTest.getExpectedResponsePayload(); if (expectedPayload != null && expectedPayload.trim().length() > 0) { Assert.fail("Expected a payload but didn't get one."); } } /** * Assume the payload is JSON and do some assertions based on the configuration in the REST * Test. * * @param restTest * @param response */ private void assertJsonPayload(RestTest restTest, Response response) { InputStream inputStream = null; try { inputStream = new ByteArrayInputStream(response.binary()); ObjectMapper jacksonParser = new ObjectMapper(); JsonNode actualJson = jacksonParser.readTree(inputStream); bindVariables(actualJson, restTest); String expectedPayload = TestUtil.doPropertyReplacement(restTest.getExpectedResponsePayload()); Assert.assertNotNull("REST Test missing expected JSON payload.", expectedPayload); JsonNode expectedJson = jacksonParser.readTree(expectedPayload); try { JsonCompare jsonCompare = new JsonCompare(); jsonCompare.setArrayOrdering(JsonArrayOrderingType.fromString(restTest.getExpectedResponseHeaders().get("X-RestTest-ArrayOrdering"))); jsonCompare.setIgnoreCase("true".equals(restTest.getExpectedResponseHeaders().get("X-RestTest-Assert-IgnoreCase"))); jsonCompare.setCompareNumericIds("true".equals(restTest.getExpectedResponseHeaders().get("X-RestTest-Assert-NumericIds"))); jsonCompare.setMissingField( JsonMissingFieldType.fromString(restTest.getExpectedResponseHeaders().get("X-RestTest-Assert-MissingField"))); jsonCompare.assertJson(expectedJson, actualJson); } catch (Error e) { System.out.println("--- START FAILED JSON PAYLOAD ---"); System.out.println(actualJson.toString()); System.out.println("--- END FAILED JSON PAYLOAD ---"); throw e; } } catch (Exception e) { throw new Error(e); } finally { IOUtils.closeQuietly(inputStream); } } /** * The payload is expected to be XML. Parse it and then use XmlUnit to compare the payload * with the expected payload (obviously also XML). * * @param restTest * @param response */ private void assertXmlPayload(RestTest restTest, com.jcabi.http.Response response) { InputStream inputStream = null; try { inputStream = new ByteArrayInputStream(response.binary()); StringWriter writer = new StringWriter(); IOUtils.copy(inputStream, writer); String xmlPayload = writer.toString(); String expectedPayload = TestUtil.doPropertyReplacement(restTest.getExpectedResponsePayload()); Assert.assertNotNull("REST Test missing expected XML payload.", expectedPayload); try { XMLUnit.setIgnoreComments(true); XMLUnit.setIgnoreAttributeOrder(true); XMLUnit.setIgnoreWhitespace(true); XMLUnit.setIgnoreDiffBetweenTextAndCDATA(true); XMLUnit.setCompareUnmatched(false); Diff diff = new Diff(expectedPayload, xmlPayload); // A custom element qualifier allows us to customize how the diff engine // compares the XML nodes. In this case, we're specially handling any // elements named "entry" so that we can compare the standard XML format // of the Echo API we use for most of our tests. The format of an // entry looks like: // <entry> // <key>Name</key> // <value>Value</value> // </entry> diff.overrideElementQualifier(new ElementNameQualifier() { @Override public boolean qualifyForComparison(Element control, Element test) { if (control == null || test == null) { return super.qualifyForComparison(control, test); } if (control.getNodeName().equals("entry") && test.getNodeName().equals("entry")) { String controlKeyName = control.getElementsByTagName("key").item(0).getTextContent(); String testKeyName = test.getElementsByTagName("key").item(0).getTextContent(); return controlKeyName.equals(testKeyName); } return super.qualifyForComparison(control, test); } }); diff.overrideDifferenceListener(new DifferenceListener() { @Override public void skippedComparison(Node control, Node test) { } @Override public int differenceFound(Difference difference) { String value = difference.getControlNodeDetail().getValue(); String tvalue = null; if (difference.getControlNodeDetail().getNode() != null) { tvalue = difference.getControlNodeDetail().getNode().getTextContent(); } if ("*".equals(value) || "*".equals(tvalue)) { return RETURN_IGNORE_DIFFERENCE_NODES_IDENTICAL; } else { return RETURN_ACCEPT_DIFFERENCE; } } }); XMLAssert.assertXMLEqual(null, diff, true); } catch (Error e) { System.out.println("--- START FAILED XML PAYLOAD ---"); System.out.println(xmlPayload); System.out.println("--- END FAILED XML PAYLOAD ---"); throw e; } } catch (Exception e) { throw new Error(e); } finally { IOUtils.closeQuietly(inputStream); } } /** * Binds any variables found in the response JSON to system properties so they can be used * in later rest tests. * * @param actualJson * @param restTest */ private void bindVariables(JsonNode actualJson, RestTest restTest) { for (String headerName : restTest.getExpectedResponseHeaders().keySet()) { if (headerName.startsWith("X-RestTest-BindTo-")) { String bindExpression = restTest.getExpectedResponseHeaders().get(headerName); String bindVarName = headerName.substring("X-RestTest-BindTo-".length()); String bindValue = evaluate(bindExpression, actualJson); log("-- Binding value in response --"); log("\tExpression: " + bindExpression); log("\t To Var: " + bindVarName); log("\t New Value: " + bindValue); if (bindValue == null) { System.clearProperty(bindVarName); } else { System.setProperty(bindVarName, bindValue); } } } } /** * Evaluates the given expression against the given JSON object. * * @param bindExpression * @param json */ private String evaluate(String bindExpression, final JsonNode json) { PropertyHandlerFactory.registerPropertyHandler(ObjectNode.class, new PropertyHandler() { @Override public Object setProperty(String name, Object contextObj, VariableResolverFactory variableFactory, Object value) { throw new RuntimeException("Not supported!"); } @Override public Object getProperty(String name, Object contextObj, VariableResolverFactory variableFactory) { ObjectNode node = (ObjectNode) contextObj; TestVariableResolver resolver = new TestVariableResolver(node, name); return resolver.getValue(); } }); return String.valueOf(MVEL.eval(bindExpression, new TestVariableResolverFactory(json))); } /** * Assume the payload is Text and do some assertions based on the configuration in the REST * Test. * * @param restTest * @param response */ private void assertTextPayload(RestTest restTest, com.jcabi.http.Response response) { InputStream inputStream = null; try { inputStream = new ByteArrayInputStream(response.binary()); List<String> lines = IOUtils.readLines(inputStream); StringBuilder builder = new StringBuilder(); for (String line : lines) { builder.append(line).append("\n"); } String actual = builder.toString(); String expected = restTest.getExpectedResponsePayload(); if (expected != null) { Assert.assertEquals("Response payload (text/plain) mismatch.", expected, actual); } } catch (Exception e) { throw new Error(e); } finally { IOUtils.closeQuietly(inputStream); } } /** * Gets the absolute URL to use to invoke a rest API at a given path. * * @param path * @throws URISyntaxException */ public URI getUri(String baseApiUrl, String path) throws URISyntaxException { if (baseApiUrl.endsWith("/")) { baseApiUrl = baseApiUrl.substring(0, baseApiUrl.length() - 1); } if (path == null) { return new URI(baseApiUrl); } else { return new URI(baseApiUrl + path); } } /** * Logs a message. * * @param message * @param params */ private void log(String message, Object... params) { String outmsg = MessageFormat.format(message, params); logger.info(" >> " + outmsg); } /** * Logs a message. * * @param message */ private void logPlain(String message) { logger.info(" >> " + message); } }