/* * 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; import org.apache.commons.lang3.StringUtils; import org.apache.http.HttpEntity; import org.apache.http.HttpRequestInterceptor; import org.apache.http.HttpResponseInterceptor; import org.apache.http.NameValuePair; import org.apache.http.annotation.Immutable; import org.apache.http.client.CookieStore; import org.apache.http.client.CredentialsProvider; import org.apache.http.client.RedirectStrategy; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.entity.ContentType; import org.apache.http.entity.mime.MultipartEntityBuilder; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.sling.testing.clients.interceptors.DelayRequestInterceptor; import org.apache.sling.testing.clients.interceptors.TestDescriptionInterceptor; 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.AbstractPoller; import org.apache.sling.testing.clients.util.poller.Polling; import org.codehaus.jackson.JsonNode; import java.io.File; import java.net.URI; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeoutException; import static org.apache.http.HttpStatus.SC_CREATED; import static org.apache.http.HttpStatus.SC_OK; /** * <p>The Base class for all Integration Test Clients. It provides generic methods to send HTTP requests to a server. </p> * * <p>It has methods to perform simple node operations on the server like creating and deleting nodes, etc. * on the server using requests. </p> */ @Immutable public class SlingClient extends AbstractSlingClient { public static final String DEFAULT_NODE_TYPE = "sling:OrderedFolder"; /** * Constructor used by Builders and adaptTo(). <b>Should never be called directly from the code.</b> * * @param http the underlying HttpClient to be used * @param config sling specific configs * @throws ClientException if the client could not be created * * @see AbstractSlingClient#AbstractSlingClient(CloseableHttpClient, SlingClientConfig) */ public SlingClient(CloseableHttpClient http, SlingClientConfig config) throws ClientException { super(http, config); } /** * <p>Handy constructor easy to use in simple tests. Creates a client that uses basic authentication.</p> * * <p>For constructing clients with complex configurations, use a {@link InternalBuilder}</p> * * <p>For constructing clients with the same configuration, but a different class, use {@link #adaptTo(Class)}</p> * * @param url url of the server (including context path) * @param user username for basic authentication * @param password password for basic authentication * @throws ClientException never, kept for uniformity with the other constructors */ public SlingClient(URI url, String user, String password) throws ClientException { super(Builder.create(url, user, password).buildHttpClient(), Builder.create(url, user, password).buildSlingClientConfig()); } /** * Moves a sling path to a new location (:operation move) * * @param srcPath source path * @param destPath destination path * @param expectedStatus list of accepted status codes in response * @return the response * @throws ClientException if an error occurs during operation */ public SlingHttpResponse move(String srcPath, String destPath, int... expectedStatus) throws ClientException { UrlEncodedFormEntity entity = FormEntityBuilder.create() .addParameter(":operation", "move") .addParameter(":dest", destPath) .build(); return this.doPost(srcPath, entity, expectedStatus); } /** * Deletes a sling path (:operation delete) * * @param path path to be deleted * @param expectedStatus list of accepted status codes in response * @return the response * @throws ClientException if an error occurs during operation */ public SlingHttpResponse deletePath(String path, int... expectedStatus) throws ClientException { HttpEntity entity = FormEntityBuilder.create().addParameter(":operation", "delete").build(); return this.doPost(path, entity, expectedStatus); } /** * Recursively creates all the none existing nodes in the given path using the {@link SlingClient#createNode(String, String)} method. * All the created nodes will have the given node type. * * @param path the path to use for creating all the none existing nodes * @param nodeType the node type to use for the created nodes * @return the response to the creation of the leaf node * @throws ClientException if one of the nodes can't be created */ public SlingHttpResponse createNodeRecursive(final String path, final String nodeType) throws ClientException { final String parentPath = getParentPath(path); if (!parentPath.isEmpty() && !exists(parentPath)) { createNodeRecursive(parentPath, nodeType); } return createNode(path, nodeType); } /** * Creates the node specified by a given path with the given node type.<br> * If the given node type is {@code null}, the node will be created with the default type: {@value DEFAULT_NODE_TYPE}.<br> * If the node already exists, the method will return null, with no errors.<br> * The method ignores trailing slashes so a path like this <i>/a/b/c///</i> is accepted and will create the <i>c</i> node if the rest of * the path exists. * * @param path the path to the node to create * @param nodeType the type of the node to create * @return the sling HTTP response or null if the path already existed * @throws ClientException if the node can't be created */ public SlingHttpResponse createNode(final String path, final String nodeType) throws ClientException { if (!exists(path)) { String nodeTypeValue = nodeType; if (nodeTypeValue == null) { nodeTypeValue = DEFAULT_NODE_TYPE; } // Use the property for creating the actual node for working around the Sling issue with dot containing node names. // The request will be similar with doing: // curl -F "nodeName/jcr:primaryType=nodeTypeValue" -u admin:admin http://localhost:8080/nodeParentPath final String nodeName = getNodeNameFromPath(path); final String nodeParentPath = getParentPath(path); final HttpEntity entity = FormEntityBuilder.create().addParameter(nodeName + "/jcr:primaryType", nodeTypeValue).build(); return this.doPost(nodeParentPath, entity, SC_OK, SC_CREATED); } else { return null; } } /** * <p>Checks whether a path exists or not by making a GET request to that path with the {@code json} extension</p> * @param path path to be checked * @return true if GET response returns 200 * @throws ClientException if the request could not be performed */ public boolean exists(String path) throws ClientException { SlingHttpResponse response = this.doGet(path + ".json"); final int status = response.getStatusLine().getStatusCode(); return status == SC_OK; } /** * Extracts the parent path from the given String * * @param path string containing the path * @return the parent path if exists or empty string otherwise */ protected String getParentPath(final String path) { // TODO define more precisely what is the parent of a folder and of a file final String normalizedPath = StringUtils.removeEnd(path, "/"); // remove trailing slash in case of folders return StringUtils.substringBeforeLast(normalizedPath, "/"); } /** * Extracts the node from path * * @param path string containing the path * @return the node without parent path */ protected String getNodeNameFromPath(final String path) { // TODO define the output for all the cases (e.g. paths with trailing slash) final String normalizedPath = StringUtils.removeEnd(path, "/"); // remove trailing slash in case of folders final int pos = normalizedPath.lastIndexOf('/'); if (pos != -1) { return normalizedPath.substring(pos + 1, normalizedPath.length()); } return normalizedPath; } /** * <p>Checks whether a path exists or not by making a GET request to that path with the {@code json extension} </p> * <p>It polls the server and waits until the path exists </p> * * @deprecated use {@link #waitExists(String, long, long)} instead. * * @param path path to be checked * @param waitMillis time to wait between retries * @param retryCount number of retries before throwing an exception * @throws ClientException if the path was not found * @throws InterruptedException to mark this operation as "waiting" */ @Deprecated public void waitUntilExists(final String path, final long waitMillis, int retryCount) throws ClientException, InterruptedException { AbstractPoller poller = new AbstractPoller(waitMillis, retryCount) { boolean found = false; public boolean call() { try { found = exists(path); } catch (ClientException e) { // maybe log found = false; } return true; } public boolean condition() { return found; } }; boolean found = poller.callUntilCondition(); if (!found) { throw new ClientException("path " + path + " does not exist after " + retryCount + " retries"); } } /** * <p>Waits until a path exists by making successive GET requests to that path with the {@code json extension} </p> * <p>Polls the server until the path exists or until timeout is reached </p> * @param path path to be checked * @param timeout max total time to wait, in milliseconds * @param delay time to wait between checks, in milliseconds * @throws TimeoutException if the path was not found before timeout * @throws InterruptedException to mark this operation as "waiting", should be rethrown by callers * @since 1.1.0 */ public void waitExists(final String path, final long timeout, final long delay) throws TimeoutException, InterruptedException { Polling p = new Polling() { @Override public Boolean call() throws Exception { return exists(path); } @Override protected String message() { return "Path " + path + " does not exist after %1$d ms"; } }; p.poll(timeout, delay); } /** * Sets String component property on a node. * * @param nodePath path to the node to be edited * @param propName name of the property to be edited * @param propValue value of the property to be edited * @param expectedStatus list of expected HTTP Status to be returned, if not set, 200 is assumed. * @return the response object * @throws ClientException if something fails during the request/response cycle */ public SlingHttpResponse setPropertyString(String nodePath, String propName, String propValue, int... expectedStatus) throws ClientException { // prepare the form HttpEntity formEntry = FormEntityBuilder.create().addParameter(propName, propValue).build(); // send the request return this.doPost(nodePath, formEntry, HttpUtils.getExpectedStatus(SC_OK, expectedStatus)); } /** * Sets a String[] component property on a node. * * @param nodePath path to the node to be edited * @param propName name of the property to be edited * @param propValueList List of String values * @param expectedStatus list of expected HTTP Status to be returned, if not set, 200 is assumed. * @return the response * @throws ClientException if something fails during the request/response cycle */ public SlingHttpResponse setPropertyStringArray(String nodePath, String propName, List<String> propValueList, int... expectedStatus) throws ClientException { // prepare the form FormEntityBuilder formEntry = FormEntityBuilder.create(); for (String propValue : (propValueList != null) ? propValueList : new ArrayList<String>(0)) { formEntry.addParameter(propName, propValue); } // send the request and return the sling response return this.doPost(nodePath, formEntry.build(), HttpUtils.getExpectedStatus(SC_OK, expectedStatus)); } /** * Sets multiple String properties on a node in a single request * @param nodePath path to the node to be edited * @param properties list of NameValue pairs with the name and value for each property. String[] properties can be defined * by adding multiple time the same property name with different values * @param expectedStatus list of expected HTTP Status to be returned, if not set, 200 is assumed. * @return the response * @throws ClientException if the operation could not be completed */ public SlingHttpResponse setPropertiesString(String nodePath, List<NameValuePair> properties, int... expectedStatus) throws ClientException { // prepare the form HttpEntity formEntry = FormEntityBuilder.create().addAllParameters(properties).build(); // send the request and return the sling response return this.doPost(nodePath, formEntry, HttpUtils.getExpectedStatus(SC_OK, expectedStatus)); } /** * Returns the JSON content of a node already mapped to a {@link org.codehaus.jackson.JsonNode}.<br> * Waits max 10 seconds for the node to be created. * * @deprecated use {@link #waitExists(String, long, long)} and {@link #doGetJson(String, int, int...)} instead * @param path the path to the content node * @param depth the number of levels to go down the tree, -1 for infinity * @return a {@link org.codehaus.jackson.JsonNode} mapping to the requested content node. * @throws ClientException if something fails during request/response processing * @throws InterruptedException to mark this operation as "waiting" */ @Deprecated public JsonNode getJsonNode(String path, int depth) throws ClientException, InterruptedException { return getJsonNode(path, depth, 500, 20); } /** * Returns JSON format of a content node already mapped to a {@link org.codehaus.jackson.JsonNode}. * * @deprecated use {@link #waitExists(String, long, long)} and {@link #doGetJson(String, int, int...)} instead * @param path the path to the content node * @param depth the number of levels to go down the tree, -1 for infinity * @param waitMillis how long it should wait between requests * @param retryNumber number of retries before throwing an exception * @param expectedStatus list of allowed HTTP Status to be returned. If not set, * http status 200 (OK) is assumed. * @return a {@link org.codehaus.jackson.JsonNode} mapping to the requested content node. * @throws ClientException if something fails during request/response cycle * @throws InterruptedException to mark this operation as "waiting" */ @Deprecated public JsonNode getJsonNode(String path, int depth, final long waitMillis, final int retryNumber, int... expectedStatus) throws ClientException, InterruptedException { // check if path exist and wait if needed waitUntilExists(path, waitMillis, retryNumber); // check for infinity if (depth == -1) { path += ".infinity.json"; } else { path += "." + depth + ".json"; } // request the JSON for the page node SlingHttpResponse response = this.doGet(path); HttpUtils.verifyHttpStatus(response, HttpUtils.getExpectedStatus(SC_OK, expectedStatus)); return JsonUtils.getJsonNodeFromString(response.getContent()); } /** * Returns the {@link org.codehaus.jackson.JsonNode} object corresponding to a content node. * * @param path the path to the content node * @param depth the number of levels to go down the tree, -1 for infinity * @param expectedStatus list of allowed HTTP Status to be returned. If not set, 200 (OK) is assumed. * * @return a {@link org.codehaus.jackson.JsonNode} mapping to the requested content node. * @throws ClientException if the path does not exist or something fails during request/response cycle * @since 1.1.0 */ public JsonNode doGetJson(String path, int depth, int... expectedStatus) throws ClientException { // check for infinity if (depth == -1) { path += ".infinity.json"; } else { path += "." + depth + ".json"; } // request the JSON for the node SlingHttpResponse response = this.doGet(path, HttpUtils.getExpectedStatus(SC_OK, expectedStatus)); return JsonUtils.getJsonNodeFromString(response.getContent()); } /** * Uploads a file to the repository. It creates a leaf node typed {@code nt:file}. The intermediary nodes are created with * type "sling:OrderedFolder" if parameter {@code createFolders} is true * * @param file the file to be uploaded * @param mimeType the MIME Type of the file * @param toPath the complete path of the file in the repository including file name * @param createFolders if true, all non existing parent nodes will be created using node type {@code sling:OrderedFolder} * @param expectedStatus list of expected HTTP Status to be returned, if not set, 201 is assumed. * @return the response * @throws ClientException if something fails during the request/response cycle */ public SlingHttpResponse upload(File file, String mimeType, String toPath, boolean createFolders, int... expectedStatus) throws ClientException { // Determine filename and parent folder, depending on whether toPath is a folder or a file String toFileName; String toFolder; if (toPath.endsWith("/")) { toFileName = file.getName(); toFolder = toPath; } else { toFileName = getNodeNameFromPath(toPath); toFolder = getParentPath(toPath); } if (createFolders) { createNodeRecursive(toFolder, "sling:OrderedFolder"); } if (mimeType == null) { mimeType = "application/octet-stream"; } HttpEntity entity = MultipartEntityBuilder.create() .addBinaryBody(toFileName, file, ContentType.create(mimeType), toFileName) .build(); // return the sling response return this.doPost(toFolder, entity, HttpUtils.getExpectedStatus(SC_CREATED, expectedStatus)); } /** * Creates a new Folder of type sling:OrderedFolder. Same as using {@code New Folder...} in the Site Admin. * * @param folderName The name of the folder to be used in the URL. * @param folderTitle Title of the Folder to be set in jcr:title * @param parentPath The parent path where the folder gets added. * @param expectedStatus list of expected HTTP Status to be returned, if not set, 201 is assumed. * @return the response * @throws ClientException if something fails during the request/response cycle */ public SlingHttpResponse createFolder(String folderName, String folderTitle, String parentPath, int... expectedStatus) throws ClientException { // we assume the parentPath is a folder, even though it doesn't end with a slash parentPath = StringUtils.appendIfMissing(parentPath, "/"); String folderPath = parentPath + folderName; HttpEntity feb = FormEntityBuilder.create() .addParameter("./jcr:primaryType", "sling:OrderedFolder") // set primary type for folder node .addParameter("./jcr:content/jcr:primaryType", "nt:unstructured") // add jcr:content as sub node .addParameter("./jcr:content/jcr:title", folderTitle) //set the title .build(); // execute request and return the sling response return this.doPost(folderPath, feb, HttpUtils.getExpectedStatus(SC_CREATED, expectedStatus)); } /** * Get the UUID of a repository path * * @param path path in repository * @return uuid as String or null if path does not exist * @throws ClientException if something fails during request/response cycle */ public String getUUID(String path) throws ClientException { if (!exists(path)) { return null; } JsonNode jsonNode = doGetJson(path, -1); return getUUId(jsonNode); } /** * Get the UUID from a node that was already parsed in a {@link JsonNode} * * @param jsonNode {@link JsonNode} object of the repository node * @return UUID as String or null if jsonNode is null or if the UUID was not found * @throws ClientException if something fails during request/response cycle */ // TODO make this method static public String getUUId(JsonNode jsonNode) throws ClientException { if (jsonNode == null) { return null; } JsonNode uuidNode = jsonNode.get("jcr:uuid"); if (uuidNode == null) { return null; } return uuidNode.getValueAsText(); } // // InternalBuilder class and builder related methods // /** * <p>Extensible InternalBuilder for SlingClient. Can be used by calling: {@code SlingClient.builder().create(...).build()}. * Between create() and build(), any number of <i>set</i> methods can be called to customize the client.<br> * It also exposes the underling httpClientBuilder through {@link #httpClientBuilder()} which can be used to customize the client * at http level. * </p> * * <p>The InternalBuilder is created to be easily extensible. A class, e.g. {@code MyClient extends SlingClient}, can have its own InternalBuilder. * This is worth creating if MyClient has fields that need to be initialized. The Skeleton of such InternalBuilder (created inside MyClient) is: * </p> * <blockquote><pre> * {@code * public static abstract class InternalBuilder<T extends MyClient> extends SlingClient.InternalBuilder<T> { * private String additionalField; * * public InternalBuilder(URI url, String user, String password) { super(url, user, password); } * * public InternalBuilder<T> setAdditionalField(String s) { additionalField = s; } * } * } * </pre></blockquote> * <p>Besides this, two more methods need to be implemented directly inside {@code MyClient}: </p> * <blockquote><pre> * {@code * public static InternalBuilder<?> builder(URI url, String user, String password) { * return new InternalBuilder<MyClient>(url, user, password) { * {@literal @}Override * public MyClient build() throws ClientException { return new MyClient(this); } * }; * } * * protected MyClient(InternalBuilder<MyClient> builder) throws ClientException { * super(builder); * additionalField = builder.additionalField; * } * } * </pre></blockquote> * Of course, the Clients and InternalBuilder are extensible on several levels, so MyClient.InternalBuilder can be further extended. * * @param <T> type extending SlingClient */ public static abstract class InternalBuilder<T extends SlingClient> { private final SlingClientConfig.Builder configBuilder; private final HttpClientBuilder httpClientBuilder; protected InternalBuilder(URI url, String user, String password) { this.httpClientBuilder = HttpClientBuilder.create(); this.configBuilder = SlingClientConfig.Builder.create().setUrl(url).setUser(user).setPassword(password); setDefaults(); } public InternalBuilder<T> setUrl(URI url) { this.configBuilder.setUrl(url); return this; } public InternalBuilder<T> setUser(String user) { this.configBuilder.setUser(user); return this; } public InternalBuilder<T> setPassword(String password) { this.configBuilder.setPassword(password); return this; } public InternalBuilder<T> setCredentialsProvider(CredentialsProvider cp) { this.configBuilder.setCredentialsProvider(cp); return this; } public InternalBuilder<T> setCookieStore(CookieStore cs) { this.configBuilder.setCookieStore(cs); return this; } public HttpClientBuilder httpClientBuilder() { return httpClientBuilder; } public abstract T build() throws ClientException; protected CloseableHttpClient buildHttpClient() { return httpClientBuilder.build(); } protected SlingClientConfig buildSlingClientConfig() { return configBuilder.build(); } /** * Sets defaults to the builder. * * @return this */ private InternalBuilder setDefaults() { httpClientBuilder.useSystemProperties(); httpClientBuilder.setUserAgent("Java"); // Connection httpClientBuilder.setMaxConnPerRoute(10); httpClientBuilder.setMaxConnTotal(100); // Interceptors httpClientBuilder.addInterceptorLast(new TestDescriptionInterceptor()); httpClientBuilder.addInterceptorLast(new DelayRequestInterceptor(Constants.HTTP_DELAY)); return this; } // // HttpClientBuilder delegating methods // public final InternalBuilder<T> addInterceptorFirst(final HttpResponseInterceptor itcp) { httpClientBuilder.addInterceptorFirst(itcp); return this; } /** * Adds this protocol interceptor to the tail of the protocol processing list. * <p> * Please note this value can be overridden by the {@link HttpClientBuilder#setHttpProcessor( * org.apache.http.protocol.HttpProcessor)} method. * </p> * * @param itcp the interceptor * @return this */ public final InternalBuilder<T> addInterceptorLast(final HttpResponseInterceptor itcp) { httpClientBuilder.addInterceptorLast(itcp); return this; } /** * Adds this protocol interceptor to the head of the protocol processing list. * <p> * Please note this value can be overridden by the {@link HttpClientBuilder#setHttpProcessor( * org.apache.http.protocol.HttpProcessor)} method. * </p> * * @param itcp the interceptor * @return this */ public final InternalBuilder<T> addInterceptorFirst(final HttpRequestInterceptor itcp) { httpClientBuilder.addInterceptorFirst(itcp); return this; } /** * Adds this protocol interceptor to the tail of the protocol processing list. * <p> * Please note this value can be overridden by the {@link HttpClientBuilder#setHttpProcessor( * org.apache.http.protocol.HttpProcessor)} method. * </p> * * @param itcp the interceptor * @return this */ public final InternalBuilder<T> addInterceptorLast(final HttpRequestInterceptor itcp) { httpClientBuilder.addInterceptorLast(itcp); return this; } /** * Assigns {@link RedirectStrategy} instance. * <p>Please note this value can be overridden by the {@link #disableRedirectHandling()} method.</p> * * @param redirectStrategy custom redirect strategy * @return this */ public final InternalBuilder<T> setRedirectStrategy(final RedirectStrategy redirectStrategy) { httpClientBuilder.setRedirectStrategy(redirectStrategy); return this; } /** * Disables automatic redirect handling. * * @return this */ public final InternalBuilder<T> disableRedirectHandling() { httpClientBuilder.disableRedirectHandling(); return this; } } public final static class Builder extends InternalBuilder<SlingClient> { private Builder(URI url, String user, String password) { super(url, user, password); } @Override public SlingClient build() throws ClientException { return new SlingClient(buildHttpClient(), buildSlingClientConfig()); } public static Builder create(URI url, String user, String password) { return new Builder(url, user, password); } } }