blob: 8359bf285919dd40d555528543e5091618d01f2f [file] [log] [blame]
/*
* 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> setPreemptiveAuth(boolean isPreemptiveAuth) {
this.configBuilder.setPreemptiveAuth(isPreemptiveAuth);
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);
}
}
}