Added: release/sling/src/main/java/org/apache/sling/testing/clients/osgi/OsgiConsoleClient.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/osgi/OsgiConsoleClient.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/osgi/OsgiConsoleClient.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,898 @@ +/* + * 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.sling.testing.clients.osgi; + +import org.apache.http.Header; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.sling.testing.clients.ClientException; +import org.apache.sling.testing.clients.SlingClient; +import org.apache.sling.testing.clients.SlingClientConfig; +import org.apache.sling.testing.clients.SlingHttpResponse; +import org.apache.sling.testing.clients.util.FormEntityBuilder; +import org.apache.sling.testing.clients.util.HttpUtils; +import org.apache.sling.testing.clients.util.JsonUtils; +import org.apache.sling.testing.clients.util.poller.PathPoller; +import org.apache.sling.testing.clients.util.poller.Polling; +import org.codehaus.jackson.JsonNode; +import org.osgi.framework.Constants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import java.util.jar.JarInputStream; +import java.util.jar.Manifest; + +import static org.apache.http.HttpStatus.SC_MOVED_TEMPORARILY; +import static org.apache.http.HttpStatus.SC_OK; + +/** + * A client that wraps the Felix OSGi Web Console REST API calls. + * @see <a href=http://felix.apache.org/documentation/subprojects/apache-felix-web-console/web-console-restful-api.html> + * Web Console RESTful API</a> + */ +public class OsgiConsoleClient extends SlingClient { + + private static final Logger LOG = LoggerFactory.getLogger(OsgiConsoleClient.class); + /** + * All System Console REST API calls go to /system/console and below + */ + private final String CONSOLE_ROOT_URL = "/system/console"; + + /** + * The URL for configuration requests + */ + private final String URL_CONFIGURATION = CONSOLE_ROOT_URL + "/configMgr"; + + /** + * The URL for bundle requests + */ + private final String URL_BUNDLES = CONSOLE_ROOT_URL + "/bundles"; + + /** + * The URL for components requests + */ + private final String URL_COMPONENTS = CONSOLE_ROOT_URL + "/components"; + + /** + * The URL for service requests + */ + private final String URL_SERVICES = CONSOLE_ROOT_URL + "/services"; + + + public static final String JSON_KEY_ID = "id"; + public static final String JSON_KEY_VERSION = "version"; + public static final String JSON_KEY_DATA = "data"; + public static final String JSON_KEY_STATE = "state"; + + /** + * Default constructor. Simply calls {@link SlingClient#SlingClient(URI, String, String)} + * + * @param serverUrl the URL to the server under test + * @param userName the user name used for authentication + * @param password the password for this user + * @throws ClientException if the client cannot be instantiated + */ + public OsgiConsoleClient(URI serverUrl, String userName, String password) throws ClientException { + super(serverUrl, userName, password); + } + + /** + * Constructor used by adaptTo() and InternalBuilder classes. Should not be called directly in the code + * + * @param http http client to be used for requests + * @param config sling specific configs + * @throws ClientException if the client cannot be instantiated + */ + public OsgiConsoleClient(CloseableHttpClient http, SlingClientConfig config) throws ClientException { + super(http, config); + } + + /** + * Returns the wrapper for the bundles info json + * + * @param expectedStatus list of accepted statuses of the response + * @return all the bundles info + * @throws ClientException if the response status does not match any of the expectedStatus + */ + public BundlesInfo getBundlesInfo(int... expectedStatus) throws ClientException { + // request the bundles information + SlingHttpResponse resp = this.doGet(URL_BUNDLES + ".json", HttpUtils.getExpectedStatus(SC_OK, expectedStatus)); + // return the wrapper + return new BundlesInfo(JsonUtils.getJsonNodeFromString(resp.getContent())); + } + + /** + * Returns the wrapper for the bundle info json + * + * @param id the id of the bundle + * @param expectedStatus list of accepted statuses of the response + * @return the bundle info + * @throws ClientException if the response status does not match any of the expectedStatus + */ + public BundleInfo getBundleInfo(String id, int... expectedStatus) throws ClientException { + SlingHttpResponse resp = this.doGet(URL_BUNDLES + "/" + id + ".json"); + HttpUtils.verifyHttpStatus(resp, HttpUtils.getExpectedStatus(SC_OK, expectedStatus)); + return new BundleInfo(JsonUtils.getJsonNodeFromString(resp.getContent())); + } + + /** + * Returns the wrapper for the components info json + * + * @param expectedStatus list of accepted statuses of the response + * @return the components info + * @throws ClientException if the response status does not match any of the expectedStatus + */ + public ComponentsInfo getComponentsInfo(int... expectedStatus) throws ClientException { + SlingHttpResponse resp = this.doGet(URL_COMPONENTS + ".json"); + HttpUtils.verifyHttpStatus(resp, HttpUtils.getExpectedStatus(SC_OK, expectedStatus)); + return new ComponentsInfo(JsonUtils.getJsonNodeFromString(resp.getContent())); + } + + /** + * Returns the wrapper for the component info json + * + * @param id the id of the component + * @param expectedStatus list of accepted statuses of the response + * @return the component info + * @throws ClientException if the response status does not match any of the expectedStatus + */ + public ComponentInfo getComponentInfo(String id, int expectedStatus) throws ClientException { + SlingHttpResponse resp = this.doGet(URL_COMPONENTS + "/" + id + ".json"); + HttpUtils.verifyHttpStatus(resp, HttpUtils.getExpectedStatus(SC_OK, expectedStatus)); + return new ComponentInfo(JsonUtils.getJsonNodeFromString(resp.getContent())); + } + + /** + * Returns the wrapper for the component info json + * + * @param id the id of the component + * @return the component info or {@code null} if the component with that name is not found + */ + private ComponentInfo getComponentInfo(String name) throws ClientException { + SlingHttpResponse resp = this.doGet(URL_COMPONENTS + "/" + name + ".json"); + if (HttpUtils.getHttpStatus(resp) == SC_OK) { + return new ComponentInfo(JsonUtils.getJsonNodeFromString(resp.getContent())); + } + return null; + } + + /** + * Returns the service info wrapper for all services implementing the given type. + * + * @param name the type of the service + * @return the service infos or {@code null} if no service for the given type is registered + */ + private Collection<ServiceInfo> getServiceInfos(String type) throws ClientException { + SlingHttpResponse resp = this.doGet(URL_SERVICES + ".json"); + if (HttpUtils.getHttpStatus(resp) == SC_OK) { + return new ServicesInfo(JsonUtils.getJsonNodeFromString(resp.getContent())).forType(type); + } + return null; + } + + /** + * Wait until the component with the given name is registered. This means the component must be either in state "Registered" or "Active". + * The state registered is called "satisfied" in the Felix DS Web Console + * @param componentName the component's name + * @param timeout how long to wait for the component to become registered before throwing a {@code TimeoutException} in milliseconds + * @param delay time to wait between checks of the state in milliseconds + * @throws TimeoutException if the component did not become registered before timeout was reached + * @throws InterruptedException if interrupted + * @see "OSGi Comp. R6, §112.5 Component Life Cycle" + */ + public void waitComponentRegistered(final String componentName, final long timeout, final long delay) throws TimeoutException, InterruptedException { + Polling p = new Polling() { + @Override + public Boolean call() throws Exception { + ComponentInfo info = getComponentInfo(componentName); + if (info != null) { + return ((info.getStatus() == Component.Status.SATISFIED) || (info.getStatus() == Component.Status.ACTIVE)); + } else { + LOG.debug("Could not get component info for component name {}", componentName); + } + return false; + } + + @Override + protected String message() { + return "Component " + componentName + " was not registered in %1$d ms"; + } + }; + p.poll(timeout, delay); + } + + /** + * Wait until the service with the given name is registered. This means the component must be either in state "Registered" or "Active". + * @param type the type of the service (usually the name of a Java interface) + * @param bundleSymbolicName the symbolic name of the bundle supposed to register that service. + * May be {@code null} in which case this method just waits for any service with the requested type being registered (independent of the registering bundle). + * @param timeout how long to wait for the component to become registered before throwing a {@code TimeoutException} in milliseconds + * @param delay time to wait between checks of the state in milliseconds + * @throws TimeoutException if the component did not become registered before timeout was reached + * @throws InterruptedException if interrupted + */ + public void waitServiceRegistered(final String type, final String bundleSymbolicName , final long timeout, final long delay) throws TimeoutException, InterruptedException { + Polling p = new Polling() { + @Override + public Boolean call() throws Exception { + Collection<ServiceInfo> infos = getServiceInfos(type); + if (infos != null) { + if (bundleSymbolicName != null) { + for (ServiceInfo info : infos) { + if (bundleSymbolicName.equals(info.getBundleSymbolicName())) { + return true; + } + } + LOG.debug("Could not find service info for service type {} provided by bundle {}", type, bundleSymbolicName); + return false; + } else { + return !infos.isEmpty(); + } + } else { + LOG.debug("Could not find any service info for service type {}", type); + } + return false; + } + + @Override + protected String message() { + return "Service with type " + type + " was not registered in %1$d ms"; + } + }; + p.poll(timeout, delay); + } + + // + // OSGi configurations + // + + /** + * Returns a map of all properties set for the config referenced by the PID, where the map keys + * are the property names. + * + * @param pid the pid of the configuration + * @param expectedStatus list of accepted statuses of the response + * @return the properties as a map + * @throws ClientException if the response status does not match any of the expectedStatus + */ + public Map<String, Object> getConfiguration(String pid, int... expectedStatus) throws ClientException { + // make the request + SlingHttpResponse resp = this.doPost(URL_CONFIGURATION + "/" + pid, null); + // check the returned status + HttpUtils.verifyHttpStatus(resp, HttpUtils.getExpectedStatus(SC_OK, expectedStatus)); + // get the JSON node + JsonNode rootNode = JsonUtils.getJsonNodeFromString(resp.getContent()); + // go through the params + Map<String, Object> props = new HashMap<String, Object>(); + if(rootNode.get("properties") == null) + return props; + JsonNode properties = rootNode.get("properties"); + for(Iterator<String> it = properties.getFieldNames(); it.hasNext();) { + String propName = it.next(); + JsonNode value = properties.get(propName).get("value"); + if(value != null) { + props.put(propName, value.getValueAsText()); + continue; + } + value = properties.get(propName).get("values"); + if(value != null) { + Iterator<JsonNode> iter = value.getElements(); + List<String> list = new ArrayList<String>(); + while(iter.hasNext()) { + list.add(iter.next().getValueAsText()); + } + props.put(propName, list.toArray(new String[list.size()])); + } + } + return props; + } + /** + * Returns a map of all properties set for the config referenced by the PID, where the map keys + * are the property names. The method waits until the configuration has been set. + * + * @deprecated use {@link #waitGetConfiguration(long, String, int...)} + * + * @param waitCount The number of maximum wait intervals of 500ms. + * Between each wait interval, the method polls the backend to see if the configuration ahs been set. + * @param pid pid + * @param expectedStatus expected response status + * @return the config properties + * @throws ClientException if the response status does not match any of the expectedStatus + * @throws InterruptedException to mark this operation as "waiting" + */ + @Deprecated + public Map<String, Object> getConfigurationWithWait(long waitCount, String pid, int... expectedStatus) + throws ClientException, InterruptedException { + ConfigurationPoller poller = new ConfigurationPoller(pid, expectedStatus); + try { + poller.poll(500L * waitCount, 500); + } catch (TimeoutException e) { + throw new ClientException("Cannot retrieve configuration.", e); + } + return poller.getConfig(); + } + + /** + * Returns a map of all properties set for the config referenced by the PID, where the map keys + * are the property names. The method waits until the configuration has been set. + * + * @param timeout Maximum time to wait for the configuration to be available, in ms. + * @param pid service pid + * @param expectedStatus expected response status + * @return the config properties + * @throws ClientException if the response status does not match any of the expectedStatus + * @throws InterruptedException to mark this operation as "waiting" + * @throws TimeoutException if the timeout was reached + */ + public Map<String, Object> waitGetConfiguration(long timeout, String pid, int... expectedStatus) + throws ClientException, InterruptedException, TimeoutException { + + ConfigurationPoller poller = new ConfigurationPoller(pid, expectedStatus); + poller.poll(timeout, 500); + + return poller.getConfig(); + } + + /** + * Sets properties of a config referenced by its PID. the properties to be edited are passed as + * a map of property name,value pairs. + * + * @param PID Persistent identity string + * @param factoryPID Factory persistent identity string or {@code null} + * @param configProperties map of properties + * @param expectedStatus expected response status + * @return the location of the config + * @throws ClientException if the response status does not match any of the expectedStatus + */ + public String editConfiguration(String PID, String factoryPID, Map<String, Object> configProperties, int... expectedStatus) + throws ClientException { + FormEntityBuilder builder = FormEntityBuilder.create(); + builder.addParameter("apply", "true"); + builder.addParameter("action", "ajaxConfigManager"); + // send factory PID if set + if (factoryPID != null) { + builder.addParameter("factoryPid", factoryPID); + } + // add properties to edit + StringBuilder propertyList = new StringBuilder(""); + for (String propName : configProperties.keySet()) { + Object o = configProperties.get(propName); + if (o instanceof String) { + builder.addParameter(propName, (String)o); + } else if (o instanceof String[]) { + for (String s : (String[])o) { + builder.addParameter(propName, s); + } + } + propertyList.append(propName).append(","); + } + // cut off the last comma + builder.addParameter("propertylist", propertyList.substring(0, propertyList.length() - 1)); + // make the request + SlingHttpResponse resp = this.doPost(URL_CONFIGURATION + "/" + PID, builder.build()); + // check the returned status + HttpUtils.verifyHttpStatus(resp, HttpUtils.getExpectedStatus(SC_MOVED_TEMPORARILY, expectedStatus)); + + Header[] locationHeader = resp.getHeaders("Location"); + if (locationHeader!=null && locationHeader.length==1) { + return locationHeader[0].getValue().substring(URL_CONFIGURATION.length()+1); + } else { + return null; + } + } + + /** + * Sets properties of a config referenced by its PID. the properties to be edited are passed as + * a map of property (name,value) pairs. The method waits until the configuration has been set. + * + * @deprecated use {@link #waitEditConfiguration(long, String, String, Map, int...)} + * + * @param waitCount The number of maximum wait intervals of 500ms. + * Between each wait interval, the method polls the backend to see if the configuration ahs been set. + * @param PID Persistent identity string + * @param factoryPID Factory persistent identity string or {@code null} + * @param configProperties map of properties + * @param expectedStatus expected response status + * @return the pid + * @throws ClientException if the response status does not match any of the expectedStatus + * @throws InterruptedException to mark this operation as "waiting" + */ + @Deprecated + public String editConfigurationWithWait(int waitCount, String PID, String factoryPID, Map<String, Object> configProperties, + int... expectedStatus) throws ClientException, InterruptedException { + String pid = editConfiguration(PID, factoryPID, configProperties, expectedStatus); + getConfigurationWithWait(waitCount, pid); + return pid; + } + + /** + * Sets properties of a config referenced by its PID. the properties to be edited are passed as + * a map of property (name,value) pairs. The method waits until the configuration has been set. + * + * @param timeout Max time to wait for the configuration to be set, in ms + * @param PID Persistent identity string + * @param factoryPID Factory persistent identity string or {@code null} + * @param configProperties map of properties + * @param expectedStatus expected response status + * @return the pid + * @throws ClientException if the response status does not match any of the expectedStatus + * @throws InterruptedException to mark this operation as "waiting" + * @throws TimeoutException if the timeout was reached + */ + public String waitEditConfiguration(long timeout, String PID, String factoryPID, Map<String, Object> configProperties, + int... expectedStatus) + throws ClientException, InterruptedException, TimeoutException { + String pid = editConfiguration(PID, factoryPID, configProperties, expectedStatus); + waitGetConfiguration(timeout, pid); + return pid; + } + + /** + * Delete the config referenced by the PID + * + * @param pid pid + * @param expectedStatus expected response status + * @return the sling response + * @throws ClientException if the response status does not match any of the expectedStatus + */ + public SlingHttpResponse deleteConfiguration(String pid, int... expectedStatus) throws ClientException { + FormEntityBuilder builder = FormEntityBuilder.create(); + builder.addParameter("apply", "1"); + builder.addParameter("delete", "1"); + // make the request + SlingHttpResponse resp = this.doPost(URL_CONFIGURATION + "/" + pid, builder.build()); + // check the returned status + HttpUtils.verifyHttpStatus(resp, HttpUtils.getExpectedStatus(200, expectedStatus)); + return resp; + } + + // + // Bundles + // + + /** + * Uninstall a bundle + * @param symbolicName bundle symbolic name + * @return the sling response + * @throws ClientException if something went wrong with the request + */ + public SlingHttpResponse uninstallBundle(String symbolicName) throws ClientException { + final long bundleId = getBundleId(symbolicName); + LOG.info("Uninstalling bundle {} with bundleId {}", symbolicName, bundleId); + FormEntityBuilder builder = FormEntityBuilder.create(); + builder.addParameter("action", "uninstall"); + return this.doPost(getBundlePath(symbolicName), builder.build(), 200); + } + + /** + * Install a bundle using the Felix webconsole HTTP interface + * @param f the bundle file + * @param startBundle whether to start the bundle or not + * @return the sling response + * @throws ClientException if the request failed + */ + public SlingHttpResponse installBundle(File f, boolean startBundle) throws ClientException { + return installBundle(f, startBundle, 0); + } + + /** + * Install a bundle using the Felix webconsole HTTP interface, with a specific start level + * @param f bundle file + * @param startBundle whether to start or just install the bundle + * @param startLevel start level + * @return the sling response + * @throws ClientException if the request failed + */ + public SlingHttpResponse installBundle(File f, boolean startBundle, int startLevel) throws ClientException { + // Setup request for Felix Webconsole bundle install + MultipartEntityBuilder builder = MultipartEntityBuilder.create() + .addTextBody("action", "install") + .addBinaryBody("bundlefile", f); + if (startBundle) { + builder.addTextBody("bundlestart", "true"); + } + if (startLevel > 0) { + builder.addTextBody("bundlestartlevel", String.valueOf(startLevel)); + LOG.info("Installing bundle {} at start level {}", f.getName(), startLevel); + } else { + LOG.info("Installing bundle {} at default start level", f.getName()); + } + + return this.doPost(URL_BUNDLES, builder.build(), 302); + + } + + /** + * Check that specified bundle is installed and retries every {{waitTime}} milliseconds, until the + * bundle is installed or the number of retries was reached + * @deprecated does not respect polling practices; use {@link #waitBundleInstalled(String, long, long)} instead + * @param symbolicName the name of the bundle + * @param waitTime How many milliseconds to wait between retries + * @param retries the number of retries + * @return true if the bundle was installed until the retries stop, false otherwise + * @throws InterruptedException if interrupted + */ + @Deprecated + public boolean checkBundleInstalled(String symbolicName, int waitTime, int retries) throws InterruptedException { + final String path = getBundlePath(symbolicName, ".json"); + return new PathPoller(this, path, waitTime, retries).callAndWait(); + } + + /** + * Install a bundle using the Felix webconsole HTTP interface and wait for it to be installed + * @deprecated {@link #waitInstallBundle(File, boolean, int, long, long)} + * @param f the bundle file + * @param startBundle whether to start the bundle or not + * @param startLevel the start level of the bundle. negative values mean default start level + * @param waitTime how long to wait between retries of checking the bundle + * @param retries how many times to check for the bundle to be installed, until giving up + * @return true if the bundle was successfully installed, false otherwise + * @throws ClientException if the request failed + * @throws InterruptedException if interrupted + */ + @Deprecated + public boolean installBundleWithRetry(File f, boolean startBundle, int startLevel, int waitTime, int retries) + throws ClientException, InterruptedException { + installBundle(f, startBundle, startLevel); + try { + return this.checkBundleInstalled(OsgiConsoleClient.getBundleSymbolicName(f), waitTime, retries); + } catch (IOException e) { + throw new ClientException("Cannot get bundle symbolic name", e); + } + } + + /** + * Install a bundle using the Felix webconsole HTTP interface and wait for it to be installed. + * @param f the bundle file + * @param startBundle whether to start the bundle or not + * @param startLevel the start level of the bundle. negative values mean default start level + * @param timeout how long to wait for the bundle to be installed before throwing a {@code TimeoutException} in milliseconds + * @param delay time to wait between checks of the state in milliseconds + * @throws ClientException if the request failed + * @throws TimeoutException if the bundle did not install before timeout was reached + * @throws InterruptedException if interrupted + */ + public void waitInstallBundle(File f, boolean startBundle, int startLevel, long timeout, long delay) + throws ClientException, InterruptedException, TimeoutException { + + installBundle(f, startBundle, startLevel); + try { + waitBundleInstalled(getBundleSymbolicName(f), timeout, delay); + } catch (IOException e) { + throw new ClientException("Cannot get bundle symbolic name", e); + } + } + + /** + * Wait until the bundle is installed. + * @param symbolicName symbolic name of bundle + * @param timeout how long to wait for the bundle to be installed before throwing a {@code TimeoutException} in milliseconds + * @param delay time to wait between checks of the state in milliseconds + * @throws TimeoutException if the bundle did not install before timeout was reached + * @throws InterruptedException if interrupted + * @see "OSGi Core R6, §4.4.2 Bundle State" + */ + public void waitBundleInstalled(final String symbolicName, final long timeout, final long delay) + throws TimeoutException, InterruptedException { + + final String path = getBundlePath(symbolicName); + Polling p = new Polling() { + @Override + public Boolean call() throws Exception { + return exists(path); + } + + @Override + protected String message() { + return "Bundle " + symbolicName + " did not install in %1$d ms"; + } + }; + + p.poll(timeout, delay); + } + + /** + * Wait until the bundle is started + * @param symbolicName symbolic name of bundle + * @param timeout how long to wait for the bundle to be installed before throwing a {@code TimeoutException} in milliseconds. + * @param delay time to wait between checks of the state in milliseconds. + * @throws TimeoutException if the bundle did not install before timeout was reached + * @throws InterruptedException if interrupted + * @see "OSGi Core R6, §4.4.2 Bundle State" + */ + public void waitBundleStarted(final String symbolicName, final long timeout, final long delay) + throws TimeoutException, InterruptedException { + + Polling p = new Polling() { + @Override + public Boolean call() throws Exception { + try { + BundleInfo bundleInfo = getBundleInfo(symbolicName, 200); + return (bundleInfo.getStatus() == Bundle.Status.ACTIVE); + } catch (ClientException e) { + LOG.debug("Could not get bundle state for {}: {}", symbolicName, e.getLocalizedMessage(), e); + return false; + } + } + + @Override + protected String message() { + return "Bundle " + symbolicName + " did not start in %1$d ms"; + } + }; + + p.poll(timeout, delay); + } + + /** + * Get the id of the bundle + * @param symbolicName bundle symbolic name + * @return the id + * @throws ClientException if the id cannot be retrieved + */ + public long getBundleId(String symbolicName) throws ClientException { + final JsonNode bundle = getBundleData(symbolicName); + final JsonNode idNode = bundle.get(JSON_KEY_ID); + + if (idNode == null) { + throw new ClientException("Cannot get id from bundle json"); + } + + return idNode.getLongValue(); + } + + /** + * Get the version of the bundle + * @param symbolicName bundle symbolic name + * @return bundle version + * @throws ClientException if the version is not retrieved + */ + public String getBundleVersion(String symbolicName) throws ClientException { + final JsonNode bundle = getBundleData(symbolicName); + final JsonNode versionNode = bundle.get(JSON_KEY_VERSION); + + if (versionNode == null) { + throw new ClientException("Cannot get version from bundle json"); + } + + return versionNode.getTextValue(); + } + + /** + * Get the state of the bundle + * @param symbolicName bundle symbolic name + * @return the state of the bundle + * @throws ClientException if the state cannot be retrieved + */ + public String getBundleState(String symbolicName) throws ClientException { + final JsonNode bundle = getBundleData(symbolicName); + final JsonNode stateNode = bundle.get(JSON_KEY_STATE); + + if (stateNode == null) { + throw new ClientException("Cannot get state from bundle json"); + } + + return stateNode.getTextValue(); + } + + /** + * Starts a bundle + * @param symbolicName the name of the bundle + * @throws ClientException if the request failed + */ + public void startBundle(String symbolicName) throws ClientException { + // To start the bundle we POST action=start to its URL + final String path = getBundlePath(symbolicName); + LOG.info("Starting bundle {} via {}", symbolicName, path); + this.doPost(path, FormEntityBuilder.create().addParameter("action", "start").build(), SC_OK); + } + + /** + * Stop a bundle + * @param symbolicName the name of the bundle + * @throws ClientException if the request failed + */ + public void stopBundle(String symbolicName) throws ClientException { + // To stop the bundle we POST action=stop to its URL + final String path = getBundlePath(symbolicName); + LOG.info("Stopping bundle {} via {}", symbolicName, path); + this.doPost(path, FormEntityBuilder.create().addParameter("action", "stop").build(), SC_OK); + } + + + /** + * Starts a bundle and waits for it to be started + * @deprecated use {@link #waitStartBundle(String, long, long)} + * @param symbolicName the name of the bundle + * @param waitTime How many milliseconds to wait between retries + * @param retries the number of retries + * @throws ClientException if the request failed + * @throws InterruptedException if interrupted + */ + @Deprecated + public void startBundlewithWait(String symbolicName, int waitTime, int retries) + throws ClientException, InterruptedException { + // start a bundle + startBundle(symbolicName); + // wait for it to be in the started state + checkBundleInstalled(symbolicName, waitTime, retries); + } + + /** + * Starts a bundle and waits for it to be started + * @param symbolicName the name of the bundle + * @param timeout max time to wait for the bundle to start, in ms + * @param delay time to wait between status checks, in ms + * @throws ClientException if the request failed + * @throws InterruptedException if interrupted + * @throws TimeoutException if starting timed out + */ + public void waitStartBundle(String symbolicName, long timeout, long delay) + throws ClientException, InterruptedException, TimeoutException { + startBundle(symbolicName); + // FIXME this should wait for the started state + waitBundleInstalled(symbolicName, timeout, delay); + } + + /** + * Calls PackageAdmin.refreshPackages to force re-wiring of all the bundles. + * @throws ClientException if the request failed + */ + public void refreshPackages() throws ClientException { + LOG.info("Refreshing packages."); + FormEntityBuilder builder = FormEntityBuilder.create(); + builder.addParameter("action", "refreshPackages"); + this.doPost(URL_BUNDLES, builder.build(), 200); + } + + + // + // private methods + // + + private String getBundlePath(String symbolicName, String extension) { + return getBundlePath(symbolicName) + extension; + } + + private String getBundlePath(String symbolicName) { + return URL_BUNDLES + "/" + symbolicName; + } + + /** + * Returns a data structure like: + * + * { + * "status" : "Bundle information: 173 bundles in total - all 173 bundles active.", + * "s" : [173,171,2,0,0], + * "data": [{ + * "id":0, + * "name":"System Bundle", + * "fragment":false, + * "stateRaw":32, + * "state":"Active", + * "version":"3.0.7", + * "symbolicName":"org.apache.felix.framework", + * "category":"" + * }] + * } + */ + private JsonNode getBundleData(String symbolicName) throws ClientException { + final String path = getBundlePath(symbolicName, ".json"); + final String content = this.doGet(path, SC_OK).getContent(); + final JsonNode root = JsonUtils.getJsonNodeFromString(content); + + if (root.get(JSON_KEY_DATA) == null) { + throw new ClientException(path + " does not provide '" + JSON_KEY_DATA + "' element, JSON content=" + content); + } + + Iterator<JsonNode> data = root.get(JSON_KEY_DATA).getElements(); + if (!data.hasNext()) { + throw new ClientException(path + "." + JSON_KEY_DATA + " is empty, JSON content=" + content); + } + + final JsonNode bundle = data.next(); + if (bundle.get(JSON_KEY_STATE) == null) { + throw new ClientException(path + ".data[0].state missing, JSON content=" + content); + } + + return bundle; + } + + // + // static methods + // + + /** + * Get the symbolic name from a bundle file by looking at the manifest + * @param bundleFile bundle file + * @return the name extracted from the manifest + * @throws IOException if reading the jar failed + */ + public static String getBundleSymbolicName(File bundleFile) throws IOException { + String name = null; + final JarInputStream jis = new JarInputStream(new FileInputStream(bundleFile)); + try { + final Manifest m = jis.getManifest(); + if (m == null) { + throw new IOException("Manifest is null in " + bundleFile.getAbsolutePath()); + } + name = m.getMainAttributes().getValue(Constants.BUNDLE_SYMBOLICNAME); + } finally { + jis.close(); + } + return name; + } + + /** + * Get the version form a bundle file by looking at the manifest + * @param bundleFile bundle file + * @return the version + * @throws IOException if reading the bundle jar failed + */ + public static String getBundleVersionFromFile(File bundleFile) throws IOException { + String version = null; + final JarInputStream jis = new JarInputStream(new FileInputStream(bundleFile)); + try { + final Manifest m = jis.getManifest(); + if(m == null) { + throw new IOException("Manifest is null in " + bundleFile.getAbsolutePath()); + } + version = m.getMainAttributes().getValue(Constants.BUNDLE_VERSION); + } finally { + jis.close(); + } + return version; + } + + + class ConfigurationPoller extends Polling { + + private final String pid; + private final int[] expectedStatus; + private Map<String, Object> config; + + public ConfigurationPoller(String pid, int... expectedStatus) { + super(); + + this.pid = pid; + this.expectedStatus = expectedStatus; + this.config = null; + } + + @Override + public Boolean call() throws Exception { + config = getConfiguration(pid, expectedStatus); + return config != null; + } + + public Map<String, Object> getConfig() { + return config; + } + } +}
Added: release/sling/src/main/java/org/apache/sling/testing/clients/osgi/OsgiInstanceConfig.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/osgi/OsgiInstanceConfig.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/osgi/OsgiInstanceConfig.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,99 @@ +/* + * 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.sling.testing.clients.osgi; + +import org.apache.sling.testing.clients.ClientException; +import org.apache.sling.testing.clients.util.config.InstanceConfig; +import org.apache.sling.testing.clients.util.config.InstanceConfigException; +import org.apache.sling.testing.clients.SlingClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.concurrent.TimeoutException; + +/** + * <p>Allows saving and restoring the OSGiConfig to be used before and after altering OSGi configurations for tests</p> + * <p>See {@link InstanceConfig}</p> + */ +public class OsgiInstanceConfig implements InstanceConfig { + private static final Logger LOG = LoggerFactory.getLogger(OsgiInstanceConfig.class); + + /** + * Time im ms to wait for retrieving the current osgi config for save() and restore() + */ + private static final long WAIT_TIMEOUT = 20000; // in ms + + private final OsgiConsoleClient osgiClient; + private final String configPID; + private Map<String, Object> config; + + @Deprecated + protected int waitCount = 20; + + /** + * + * @param client The Granite Client to be used internally + * @param configPID The PID for the OSGi configuration + * @param <T> The type of the Granite Client + * @throws ClientException if the client cannot be initialized + * @throws InstanceConfigException if the config cannot be saved + * @throws InterruptedException if interrupted + */ + public <T extends SlingClient> OsgiInstanceConfig(T client, String configPID) + throws ClientException, InstanceConfigException, InterruptedException { + this.osgiClient = client.adaptTo(OsgiConsoleClient.class); + this.configPID = configPID; + + // Save the configuration + save(); + } + + /** + * Save the current OSGi configuration for the PID defined in the constructor + * + * @throws InstanceConfigException if the config cannot be saved + */ + public InstanceConfig save() throws InstanceConfigException, InterruptedException { + try { + this.config = osgiClient.waitGetConfiguration(WAIT_TIMEOUT, this.configPID); + LOG.info("Saved OSGi config for {}. It is currently this: {}", this.configPID, this.config); + } catch (ClientException e) { + throw new InstanceConfigException("Error getting config", e); + } catch (TimeoutException e) { + throw new InstanceConfigException("Timeout of " + WAIT_TIMEOUT + " ms was reached while waiting for the configuration", e); + } + return this; + } + + /** + * Restore the current OSGi configuration for the PID defined in the constructor + * + * @throws InstanceConfigException if the config cannot be restored + */ + public InstanceConfig restore() throws InstanceConfigException, InterruptedException { + try { + osgiClient.waitEditConfiguration(WAIT_TIMEOUT, this.configPID, null, config); + LOG.info("restored OSGi config for {}. It is now this: {}", this.configPID, this.config); + } catch (ClientException e) { + throw new InstanceConfigException("Could not edit OSGi configuration", e); + } catch (TimeoutException e) { + throw new InstanceConfigException("Timeout of " + WAIT_TIMEOUT + " ms was reached while waiting for the configuration", e); + } + return this; + } +} Added: release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ServiceInfo.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ServiceInfo.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ServiceInfo.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,73 @@ +/* + * 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.sling.testing.clients.osgi; + +import java.util.List; + +import org.apache.sling.testing.clients.ClientException; +import org.codehaus.jackson.JsonNode; + +public class ServiceInfo { + + private JsonNode service; + + public ServiceInfo(JsonNode root) throws ClientException { + if(root.get("id") != null) { + service = root; + } else { + if(root.get("data") == null && root.get("data").size() < 1) { + throw new ClientException("No service info returned"); + } + service = root.get("data").get(0); + } + } + + /** + * @return the service identifier + */ + public int getId() { + return service.get("id").getIntValue(); + } + + /** + * @return the service types name + */ + public List<String> getTypes() { + // this is not a proper JSON array (https://issues.apache.org/jira/browse/FELIX-5762) + return ServicesInfo.splitPseudoJsonValueArray(service.get("types").getTextValue()); + } + + public String getPid() { + return service.get("pid").getTextValue(); + } + + /** + * @return the bundle id of the bundle exposing the service + */ + public int getBundleId() { + return service.get("bundleId").getIntValue(); + } + + /** + * @return the bundle symbolic name of bundle implementing the service + */ + public String getBundleSymbolicName() { + return service.get("bundleSymbolicName").getTextValue(); + } + +} Added: release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ServicesInfo.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ServicesInfo.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ServicesInfo.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,142 @@ +/* + * 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.sling.testing.clients.osgi; + +import org.apache.sling.testing.clients.ClientException; +import org.codehaus.jackson.JsonNode; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +/** + * A simple Wrapper around the returned JSON when requesting the status of /system/console/services + */ +public class ServicesInfo { + + private JsonNode root = null; + + /** + * The only constructor. + * + * @param root the root JSON node of the bundles info. + * @throws ClientException if the json does not contain the proper info + */ + public ServicesInfo(JsonNode root) throws ClientException { + this.root = root; + // some simple sanity checks + if(root.get("status") == null) + throw new ClientException("No Status returned!"); + if(root.get("serviceCount") == null) + throw new ClientException("No serviceCount returned!"); + } + + /** + * @return total number of bundles. + */ + public int getTotalNumOfServices() { + return root.get("serviceCount").getIntValue(); + } + + /** + * Return service info for a service with given id + * + * @param id the id of the service + * @return the BundleInfo + * @throws ClientException if the info could not be retrieved + */ + public ServiceInfo forId(String id) throws ClientException { + JsonNode serviceInfo = findBy("id", id); + return (serviceInfo != null) ? new ServiceInfo(serviceInfo) : null; + } + + /** + * Return service infos for a bundle with name {@code name} + * + * @param type the type of the service + * @return a Collection of {@link ServiceInfo}s of all services with the given type. Might be empty, never {@code null} + * @throws ClientException if the info cannot be retrieved + */ + public Collection<ServiceInfo> forType(String type) throws ClientException { + List<ServiceInfo> results = new LinkedList<>(); + List<JsonNode> serviceInfoNodes = findAllContainingValueInArray("types", type); + for (JsonNode serviceInfoNode : serviceInfoNodes) { + results.add(new ServiceInfo(serviceInfoNode)); + } + return results; + } + + private JsonNode findBy(String key, String value) { + List<JsonNode> result = findBy(key, value, true, false); + if (result.isEmpty()) { + return null; + } else { + return result.get(0); + } + } + + private List<JsonNode> findAllContainingValueInArray(String key, String value) { + return findBy(key, value, false, true); + } + + private List<JsonNode> findBy(String key, String value, boolean onlyReturnFirstMatch, boolean arrayContainingMatch) { + Iterator<JsonNode> nodes = root.get("data").getElements(); + List<JsonNode> results = new LinkedList<>(); + while(nodes.hasNext()) { + JsonNode node = nodes.next(); + if ((null != node.get(key)) && (node.get(key).isValueNode())) { + final String valueNode = node.get(key).getTextValue(); + if (arrayContainingMatch) { + if (splitPseudoJsonValueArray(valueNode).contains(value)) { + results.add(node); + } + } else { + if (valueNode.equals(value)) { + results.add(node); + } + } + } + } + return results; + } + + /** + * Array values are not returned as proper JSON array for Apache Felix. + * Therefore we need this dedicated split method, which extracts the individual values from this "pseudo" JSON array. + * Example value: + * <pre> + * [java.lang.Runnable, org.apache.sling.event.impl.jobs.queues.QueueManager, org.osgi.service.event.EventHandler] + * </pre> + * @param value the value to split + * @return the list of the individual values in the given array. + * @see <a href="https://issues.apache.org/jira/browse/FELIX-5762">FELIX-5762</a> + */ + static final List<String> splitPseudoJsonValueArray(String value) { + // is this an array? + if (value.startsWith("[") && value.length() >= 2) { + // strip of first and last character + String pureArrayValues = value.substring(1, value.length() - 1); + String[] resultArray = pureArrayValues.split(", |,"); + return Arrays.asList(resultArray); + } + return Collections.singletonList(value); + } +} \ No newline at end of file Added: release/sling/src/main/java/org/apache/sling/testing/clients/osgi/package-info.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/osgi/package-info.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/osgi/package-info.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,25 @@ +/* + * 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. + */ +/** + * OSGI testing tools. + */ +@Version("2.0.0") +package org.apache.sling.testing.clients.osgi; + +import org.osgi.annotation.versioning.Version; Added: release/sling/src/main/java/org/apache/sling/testing/clients/package-info.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/package-info.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/package-info.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,23 @@ +/* + * 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. + */ + +@Version("2.0.0") +package org.apache.sling.testing.clients; + +import org.osgi.annotation.versioning.Version; Added: release/sling/src/main/java/org/apache/sling/testing/clients/query/QueryClient.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/query/QueryClient.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/query/QueryClient.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,224 @@ +/* + * 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.sling.testing.clients.query; + +import org.apache.http.NameValuePair; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.sling.testing.clients.ClientException; +import org.apache.sling.testing.clients.SlingClient; +import org.apache.sling.testing.clients.SlingClientConfig; +import org.apache.sling.testing.clients.SlingHttpResponse; +import org.apache.sling.testing.clients.osgi.OsgiConsoleClient; +import org.apache.sling.testing.clients.query.servlet.QueryServlet; +import org.apache.sling.testing.clients.util.JsonUtils; +import org.apache.sling.testing.clients.util.URLParameterBuilder; +import org.codehaus.jackson.JsonNode; +import org.ops4j.pax.tinybundles.core.TinyBundles; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.file.Files; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; +import static org.apache.http.HttpStatus.SC_NOT_FOUND; +import static org.apache.http.HttpStatus.SC_OK; + +/** + * <p>Sling client for performing oak queries.</p> + * + * <p>Uses a custom servlet {@link QueryServlet} to execute the query on the server + * and return the results as a json. If the servlet is not yet present, it automatically + * installs it and creates the corresponding nodes</p> + * + * <p>The servlet is exposed under {@value QueryServlet#SERVLET_PATH}.</p> + * + * <p>The servlet is not automatically uninstalled to avoid too much noise on the instance. + * The caller should take care of it, if needed, by calling {@link #uninstallServlet()}</p> + */ +public class QueryClient extends SlingClient { + + /** + * Query types, as defined in {@code org.apache.jackrabbit.oak.query.QueryEngineImpl} + */ + public enum QueryType { + SQL2("JCR-SQL2"), + SQL("sql"), + XPATH("xpath"), + JQOM("JCR-JQOM"); + + private final String name; + + QueryType(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } + } + + private static final Logger LOG = LoggerFactory.getLogger(QueryClient.class); + + private static final String BUNDLE_BSN = "org.apache.sling.testing.clients.query"; + private static final String BUNDLE_NAME = "Sling Testing Clients Query Servlet"; + private static final String BUNDLE_VERSION = "1.0.0"; + + private static final long BUNDLE_START_TIMEOUT = TimeUnit.SECONDS.toMillis(10); + + /** + * Constructor used by adaptTo + * + * @param http underlying HttpClient + * @param config config state + * @throws ClientException if the client cannot be created + */ + public QueryClient(CloseableHttpClient http, SlingClientConfig config) throws ClientException { + super(http, config); + } + + /** + * Convenience constructor + * + * @param url host url + * @param user username + * @param password password + * @throws ClientException if the client cannot be constructed + */ + public QueryClient(URI url, String user, String password) throws ClientException { + super(url, user, password); + } + + /** + * Executes a query on the server and returns the results as a json + * + * @param query query to be executed + * @param type type of the query + * @return the results in json as exported by {@link QueryServlet} + * @throws ClientException if the request failed to execute + * @throws InterruptedException to mark that this method blocks + */ + public JsonNode doQuery(final String query, final QueryType type) throws ClientException, InterruptedException { + return doQuery(query, type, true, false); + } + + /** + * Executes a query on the server and returns only the number of rows in the result + * + * @param query query to be executed + * @param type type of the query + * @return total results returned by the query + * @throws ClientException if the request failed to execute + * @throws InterruptedException to mark that this method blocks + */ + public long doCount(final String query, final QueryType type) throws ClientException, InterruptedException { + return doQuery(query, type, false, false).get("total").getLongValue(); + } + + /** + * Retrieves the plan of the query. Useful for determining which index is used + * + * @param query query to be executed + * @param type type of the query + * @return total results returned by the query + * @throws ClientException if the request failed to execute + * @throws InterruptedException to mark that this method blocks + */ + public String getPlan(final String query, final QueryType type) throws ClientException, InterruptedException { + return doQuery(query, type, false, true).get("plan").toString(); + } + + protected JsonNode doQuery(final String query, final QueryType type, final boolean showResults, final boolean explain) + throws ClientException, InterruptedException { + + List<NameValuePair> params = URLParameterBuilder.create() + .add("query", query) + .add("type", type.toString()) + .add("showresults", Boolean.toString(showResults)) + .add("explain", Boolean.toString(explain)) + .getList(); + + try { + // try optimistically to execute the query + SlingHttpResponse response = this.doGet(QueryServlet.SERVLET_PATH, params, SC_OK); + return JsonUtils.getJsonNodeFromString(response.getContent()); + } catch (ClientException e) { + if (e.getHttpStatusCode() == SC_NOT_FOUND) { + LOG.info("Could not find query servlet, will try to install it"); + installServlet(); + LOG.info("Retrying the query"); + SlingHttpResponse response = this.doGet(QueryServlet.SERVLET_PATH, params, SC_OK); + return JsonUtils.getJsonNodeFromString(response.getContent()); + } else { + throw e; + } + } + } + + /** + * <p>Installs the servlet to be able to perform queries.</p> + * + * <p>By default, methods of this client automatically install the servlet if needed, + * so there is no need to explicitly call from outside</p> + * + * @return this + * @throws ClientException if the installation fails + * @throws InterruptedException to mark that this method blocks + */ + public QueryClient installServlet() throws ClientException, InterruptedException { + InputStream bundleStream = TinyBundles.bundle() + .set("Bundle-SymbolicName", BUNDLE_BSN) + .set("Bundle-Version", BUNDLE_VERSION) + .set("Bundle-Name", BUNDLE_NAME) + .add(QueryServlet.class) + .build(TinyBundles.withBnd()); + + try { + File bundleFile = File.createTempFile(BUNDLE_BSN + "-" + BUNDLE_VERSION, ".jar"); + Files.copy(bundleStream, bundleFile.toPath(), REPLACE_EXISTING); + + adaptTo(OsgiConsoleClient.class).installBundle(bundleFile, true); + adaptTo(OsgiConsoleClient.class).waitBundleStarted(BUNDLE_BSN, BUNDLE_START_TIMEOUT, 100); + + LOG.info("query servlet installed at {}", getUrl(QueryServlet.SERVLET_PATH)); + } catch (IOException e) { + throw new ClientException("Failed to create the query servlet bundle", e); + } catch (TimeoutException e) { + throw new ClientException("The query servlet bundle did not successfully start", e); + } + + return this; + } + + /** + * Deletes all the resources created by {@link #installServlet()} + * + * @return this + * @throws ClientException if any of the resources fails to uninstall + */ + public QueryClient uninstallServlet() throws ClientException { + adaptTo(OsgiConsoleClient.class).uninstallBundle(BUNDLE_BSN); + return this; + } +} Added: release/sling/src/main/java/org/apache/sling/testing/clients/query/package-info.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/query/package-info.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/query/package-info.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,25 @@ +/* + * 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. + */ +/** + * Query tools leveraging javax.jcr.query + */ +@Version("0.1.0") +package org.apache.sling.testing.clients.query; + +import org.osgi.annotation.versioning.Version; Added: release/sling/src/main/java/org/apache/sling/testing/clients/query/servlet/QueryServlet.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/query/servlet/QueryServlet.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/query/servlet/QueryServlet.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,168 @@ +/* + * 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.sling.testing.clients.query.servlet; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.sling.api.SlingHttpServletRequest; +import org.apache.sling.api.SlingHttpServletResponse; +import org.apache.sling.api.servlets.SlingSafeMethodsServlet; +import org.osgi.service.component.annotations.Component; + +import javax.jcr.Session; +import javax.jcr.query.*; +import javax.servlet.Servlet; +import javax.servlet.ServletException; +import java.io.IOException; +import java.util.Date; + +import static org.apache.sling.api.servlets.ServletResolverConstants.SLING_SERVLET_METHODS; +import static org.apache.sling.api.servlets.ServletResolverConstants.SLING_SERVLET_PATHS; + +@Component( + name = QueryServlet.SERVLET_NAME, + service = {Servlet.class}, + property = { + SLING_SERVLET_PATHS + "=" + QueryServlet.SERVLET_PATH, + SLING_SERVLET_METHODS + "=GET" + } +) +public class QueryServlet extends SlingSafeMethodsServlet { + private static final long serialVersionUID = 1L; + + public static final String SERVLET_PATH = "/system/testing/query"; + public static final String SERVLET_NAME = "Sling Testing Clients Query Servlet"; + + @Override + protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response) + throws ServletException, IOException { + + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + try { + final QueryManager qm = request.getResourceResolver().adaptTo(Session.class) + .getWorkspace().getQueryManager(); + + long before = 0; + long after = 0; + long total = 0; + + String query = request.getParameter("query"); + String type = request.getParameter("type"); + + // default for showResults is true, unless parameter is matching exactly "false" + boolean showResults = !("false".equalsIgnoreCase(request.getParameter("showresults"))); + // default for explainQuery is false, unless parameter is present and is not matching "false" + String explainParam = request.getParameter("explain"); + boolean explainQuery = (explainParam != null) && !("false".equalsIgnoreCase(explainParam)); + + boolean tidy = false; + for (String selector : request.getRequestPathInfo().getSelectors()) { + if ("tidy".equals(selector)) { + tidy = true; + } + } + + if ((query == null) || query.equals("") || (type == null) || type.equals("")) { + response.sendError(400, "Parameters query and type are required"); // invalid request + return; + } + + // prepare + if (explainQuery) { + query = "explain " + query; + } + + Query q = qm.createQuery(query, type); + + // execute + before = new Date().getTime(); + QueryResult result = q.execute(); + after = new Date().getTime(); + + // collect results + String firstSelector = null; + if (result.getSelectorNames().length > 1) { + firstSelector = result.getSelectorNames()[0]; + try { + String[] columnNames = result.getColumnNames(); + if (columnNames.length > 0) { + String firstColumnName = columnNames[0]; + int firstDot = firstColumnName.indexOf('.'); + if (firstDot > 0) { + firstSelector = firstColumnName.substring(0, firstDot); + } + } + } catch (Exception ignored) { + } + } + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode responseJson = mapper.createObjectNode(); + + if (explainQuery) { + responseJson.put("plan", result.getRows().nextRow().getValue("plan").getString()); + } else if (showResults) { + ArrayNode results = mapper.createArrayNode(); + + RowIterator rows = result.getRows(); + while (rows.hasNext()) { + Row row = rows.nextRow(); + String rowPath = (firstSelector != null) ? row.getPath(firstSelector) : row.getPath(); + String rowType = (firstSelector != null) + ? row.getNode(firstSelector).getPrimaryNodeType().getName() + : row.getNode().getPrimaryNodeType().getName(); + + ObjectNode rowJson = mapper.createObjectNode(); + rowJson.put("path", rowPath); + rowJson.put("type", rowType); + results.add(rowJson); + + total++; + } + + responseJson.set("results", results); + } else { + // only count results + RowIterator rows = result.getRows(); + while (rows.hasNext()) { + rows.nextRow(); + total++; + } + } + + responseJson.put("total", total); + responseJson.put("time", after - before); + + if (tidy) { + response.getWriter().write(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(responseJson)); + } else { + response.getWriter().write(responseJson.toString()); + } + + } catch (InvalidQueryException e) { + // Consider InvalidQueryException as an invalid request instead of sending 500 server error + response.sendError(400, e.getMessage()); + e.printStackTrace(response.getWriter()); + } catch (final Exception e) { + response.sendError(500, e.getMessage()); + e.printStackTrace(response.getWriter()); + } + } +} Added: release/sling/src/main/java/org/apache/sling/testing/clients/query/servlet/package-info.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/query/servlet/package-info.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/query/servlet/package-info.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,25 @@ +/* + * 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. + */ +/** + * Query tools leveraging javax.jcr.query + */ +@Version("1.2.2") +package org.apache.sling.testing.clients.query.servlet; + +import org.osgi.annotation.versioning.Version; Added: release/sling/src/main/java/org/apache/sling/testing/clients/util/FormEntityBuilder.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/util/FormEntityBuilder.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/util/FormEntityBuilder.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,81 @@ +/* + * 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.sling.testing.clients.util; + +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.message.BasicNameValuePair; + +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Helper for creating Entity objects for POST requests. + */ +public class FormEntityBuilder { + public final static String DEFAULT_ENCODING = "UTF-8"; + + private final List<NameValuePair> params; + private String encoding; + + public static FormEntityBuilder create() { + return new FormEntityBuilder(); + } + + FormEntityBuilder() { + params = new ArrayList<NameValuePair>(); + encoding = DEFAULT_ENCODING; + } + + public FormEntityBuilder addAllParameters(Map<String, String> parameters) { + if (parameters != null) { + for (String key : parameters.keySet()) { + addParameter(key, parameters.get(key)); + } + } + + return this; + } + + public FormEntityBuilder addAllParameters(List<NameValuePair> parameters) { + if (parameters != null) { + params.addAll(parameters); + } + + return this; + } + + public FormEntityBuilder addParameter(String name, String value) { + params.add(new BasicNameValuePair(name, value)); + return this; + } + + public FormEntityBuilder setEncoding(String encoding) { + this.encoding = encoding; + return this; + } + + public UrlEncodedFormEntity build() { + try { + return new UrlEncodedFormEntity(params, encoding); + } catch (UnsupportedEncodingException ue) { + throw new Error("Unexpected UnsupportedEncodingException", ue); + } + } +} Added: release/sling/src/main/java/org/apache/sling/testing/clients/util/HttpUtils.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/util/HttpUtils.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/util/HttpUtils.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,183 @@ +/* + * 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.sling.testing.clients.util; + +import org.apache.http.Header; +import org.apache.http.HttpResponse; +import org.apache.sling.testing.clients.ClientException; +import org.apache.sling.testing.clients.SlingHttpResponse; + + +import java.net.URI; + + +public class HttpUtils { + + /** + * Verify expected status and dump response in case expected status is not returned. + * Warning! It will try to consume the entity in case of error + * + * @param response The Sling HTTP response + * @param expectedStatus List of acceptable HTTP Statuses + * @throws ClientException if status is not expected + */ + public static void verifyHttpStatus(SlingHttpResponse response, int... expectedStatus) throws ClientException { + if (!checkStatus(response, expectedStatus)) { + throwError(response, buildDefaultErrorMessage(response), expectedStatus); + } + } + + /** + * Verify expected status and show error message in case expected status is not returned. + * + * @param response The SlingHttpResponse of an executed request. + * @param errorMessage error message; if {@code null}, errorMessage is extracted from response + * @param expectedStatus List of acceptable HTTP Statuses + * @throws ClientException if status is not expected + */ + public static void verifyHttpStatus(HttpResponse response, String errorMessage, int... expectedStatus) + throws ClientException { + if (!checkStatus(response, expectedStatus)) { + throwError(response, errorMessage, expectedStatus); + } + } + + private static boolean checkStatus(HttpResponse response, int... expectedStatus) + throws ClientException { + + // if no HttpResponse was given + if (response == null) { + throw new NullPointerException("The response is null!"); + } + + // if no expected statuses are given + if (expectedStatus == null || expectedStatus.length == 0) { + throw new IllegalArgumentException("At least one expected HTTP Status must be set!"); + } + + // get the returned HTTP Status + int givenStatus = getHttpStatus(response); + + // check if it matches with an expected one + for (int expected : expectedStatus) { + if (givenStatus == expected) { + return true; + } + } + + return false; + } + + private static boolean throwError(HttpResponse response, String errorMessage, int... expectedStatus) + throws ClientException { + // build error message + String errorMsg = "Expected HTTP Status: "; + for (int expected : expectedStatus) { + errorMsg += expected + " "; + } + + // get the returned HTTP Status + int givenStatus = getHttpStatus(response); + + errorMsg += ". Instead " + givenStatus + " was returned!\n"; + if (errorMessage != null) { + errorMsg += errorMessage; + } + + // throw the exception + throw new ClientException(errorMsg, givenStatus); + } + + + /** + * Build default error message + * + * @param resp The response of a sling request + * @return default error message + */ + public static String buildDefaultErrorMessage(SlingHttpResponse resp) { + + String content = resp.getContent(); + + // if no response content is available + if (content == null) return ""; + String errorMsg = resp.getSlingMessage(); + + errorMsg = (errorMsg == null || errorMsg.length() == 0) + // any other returned content + ? " Response Content:\n" + content + // response message from sling response + : "Error Message: \n" + errorMsg; + + return errorMsg; + } + + /** + * Get HTTP Status of the response. + * + * @param response The RequestExecutor of an executed request. + * @return The HTTP Status of the response + * @throws ClientException never (kept for uniformity) + */ + public static int getHttpStatus(HttpResponse response) throws ClientException { + return response.getStatusLine().getStatusCode(); + } + + /** + * Get the first 'Location' header and verify it's a valid URI. + * + * @param response HttpResponse the http response + * @return the location path + * @throws ClientException never (kept for uniformity) + */ + public static String getLocationHeader(HttpResponse response) throws ClientException { + if (response == null) throw new ClientException("Response must not be null!"); + + String locationPath = null; + Header locationHeader = response.getFirstHeader("Location"); + if (locationHeader != null) { + String location = locationHeader.getValue(); + URI locationURI = URI.create(location); + locationPath = locationURI.getPath(); + } + + if (locationPath == null) { + throw new ClientException("not able to determine location path"); + } + return locationPath; + } + + /** + * Check if expected status is in range + * + * @param response the http response + * @param range the http status range + * @return true if response is in range + */ + public static boolean isInHttpStatusRange(HttpResponse response, int range) { + return range == response.getStatusLine().getStatusCode() / 100 * 100; + } + + public static int[] getExpectedStatus(int defaultStatus, int... expectedStatus) { + if (expectedStatus == null || expectedStatus.length == 0) { + expectedStatus = new int[]{defaultStatus}; + } + return expectedStatus; + } + + +} \ No newline at end of file Added: release/sling/src/main/java/org/apache/sling/testing/clients/util/InputStreamBodyWithLength.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/util/InputStreamBodyWithLength.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/util/InputStreamBodyWithLength.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,69 @@ +/* + * 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.sling.testing.clients.util; + +import org.apache.http.entity.ContentType; +import org.apache.http.entity.mime.content.InputStreamBody; +import org.apache.sling.testing.clients.ClientException; + +import java.io.IOException; +import java.io.InputStream; + +/** + * If we want to upload a file that is a resource in a jar file, the http client expects a content length. + */ +public class InputStreamBodyWithLength extends InputStreamBody { + private long streamLength; + + public InputStreamBodyWithLength(String resourcePath, String contentType, String fileName) throws ClientException { + super(ResourceUtil.getResourceAsStream(resourcePath), ContentType.create(contentType), fileName); + this.streamLength = getResourceStreamLength(resourcePath); + } + + @Override + public long getContentLength() { + return streamLength; + } + + /** + * Returns the length of a resource (which is needed for the InputStreamBody + * to work. Can't currently think of a better solution than going through + * the resource stream and count. + * + * @param resourcePath path to the file + * @return the size of the resource + */ + private static long getResourceStreamLength(String resourcePath) throws ClientException { + int streamLength = 0; + InputStream stream = ResourceUtil.getResourceAsStream(resourcePath); + try { + for (int avail = stream.available(); avail > 0; avail = stream.available()) { + streamLength += avail; + stream.skip(avail); + } + } catch (IOException e) { + throw new ClientException("Could not read " + resourcePath + "!", e); + } finally { + try { + stream.close(); + } catch (IOException e) { + throw new ClientException("Could not close Inputstream for " + resourcePath + "!", e); + } + } + return streamLength; + } +} \ No newline at end of file
