Added: release/sling/src/main/java/org/apache/sling/testing/clients/ClientException.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/ClientException.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/ClientException.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,65 @@ +/* + * 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; + +/** + * + */ +public class ClientException extends Exception { + + private static final long serialVersionUID = 1L; + private int httpStatusCode = -1; + + public ClientException(String message) { + this(message, null); + } + + public ClientException(String message, Throwable throwable) { + this(message, -1, throwable); + } + + public ClientException(String message, int htmlStatusCode) { + this(message, htmlStatusCode, null); + } + + public ClientException(String message, int htmlStatusCode, Throwable throwable) { + super(message, throwable); + this.httpStatusCode = htmlStatusCode; + } + + /** + * @return the htmlStatusCode + */ + public int getHttpStatusCode() { + return httpStatusCode; + } + + /* + * (non-Javadoc) + * + * @see java.lang.Throwable#getMessage() + */ + @Override + public String getMessage() { + String message = super.getMessage(); + if (httpStatusCode > -1) { + message = message + "(return code=" + httpStatusCode + ")"; + } + return message; + } + +}
Added: release/sling/src/main/java/org/apache/sling/testing/clients/SlingClient.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/SlingClient.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/SlingClient.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,791 @@ +/* + * 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.*; +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)); + } + + /** + * <p>Create a tree structure under {@code parentPath} by providing a {@code content} in one + * of the supported formats: xml, jcr.xml, json, jar, zip.</p> + * + * <p>This is the implementation of {@code :operation import}, as documented in + * <a href="http://sling.apache.org/documentation/bundles/manipulating-content-the-slingpostservlet-servlets-post.html#importing-content-structures">importing-content-structures</a></p> + * + * @param parentPath path where the tree is created + * @param contentType format of the content + * @param content string expressing the structure to be created, in the specified format + * @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 importContent(String parentPath, String contentType, String content, int... expectedStatus) + throws ClientException { + HttpEntity entity = FormEntityBuilder.create() + .addParameter(":operation", "import") + .addParameter(":contentType", contentType) + .addParameter(":content", content) + .build(); + // execute request and return the sling response + return this.doPost(parentPath, entity, HttpUtils.getExpectedStatus(SC_CREATED, expectedStatus)); + } + + /** + * <p>Create a tree structure under {@code parentPath} by providing a {@code contentFile} in one + * of the supported formats: xml, jcr.xml, json, jar, zip.</p> + * + * <p>This is the implementation of {@code :operation import}, as documented in + * <a href="http://sling.apache.org/documentation/bundles/manipulating-content-the-slingpostservlet-servlets-post.html#importing-content-structures">importing-content-structures</a></p> + * + * @param parentPath path where the tree is created + * @param contentType format of the content + * @param contentFile file containing the structure to be created, in the specified format + * @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 importContent(String parentPath, String contentType, File contentFile, int... expectedStatus) + throws ClientException { + HttpEntity entity = MultipartEntityBuilder.create() + .addTextBody(":operation", "import") + .addTextBody(":contentType", contentType) + .addBinaryBody(":contentFile", contentFile) + .build(); + // execute request and return the sling response + return this.doPost(parentPath, entity, HttpUtils.getExpectedStatus(SC_CREATED, expectedStatus)); + } + + /** + * Wrapper method over {@link #importContent(String, String, String, int...)} for directly importing a json node + * @param parentPath path where the tree is created + * @param json json node with the desired structure + * @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 importJson(String parentPath, JsonNode json, int... expectedStatus) + throws ClientException { + return importContent(parentPath, "json", json.toString(), 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() throws ClientException { + 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(SystemPropertiesConfig.getHttpDelay())); + + // HTTP request strategy + httpClientBuilder.setServiceUnavailableRetryStrategy(new ServerErrorRetryStrategy()); + + 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); + } + } +} Added: release/sling/src/main/java/org/apache/sling/testing/clients/SlingClientConfig.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/SlingClientConfig.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/SlingClientConfig.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,249 @@ +/* + * 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 com.google.common.base.Strings; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpHost; +import org.apache.http.annotation.ThreadSafe; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.AuthCache; +import org.apache.http.client.CookieStore; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.utils.URIUtils; +import org.apache.http.impl.auth.BasicScheme; +import org.apache.http.impl.client.BasicAuthCache; +import org.apache.http.impl.client.BasicCookieStore; +import org.apache.http.impl.client.BasicCredentialsProvider; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@ThreadSafe +public class SlingClientConfig { + + /** + * Base URI of the server under test. + */ + protected final URI url; + + /** + * Name of the user that will be used to authenticate the requests. + */ + protected final String user; + + /** + * Password of the user that will be used to authenticate the requests. + */ + protected final String password; + + /** + * The cookie store + */ + protected final CookieStore cookieStore; + + /** + * The credentials provider + */ + protected final CredentialsProvider credsProvider; + + /** + * AuthCache for preemptive auth + */ + protected final AuthCache authCache; + + + /** + * Extra values to be used in interceptors, custom auth mechanisms, etc. + */ + protected final Map<String, String> values; + + + protected SlingClientConfig(URI url, String user, String password, + CookieStore cookieStore, + CredentialsProvider credentialsProvider, AuthCache authCache) { + this.url = url; + this.user = user; + this.password = password; + + this.cookieStore = cookieStore; + this.credsProvider = credentialsProvider; + this.authCache = authCache; + + this.values = new ConcurrentHashMap<String, String>(); + } + + /** + * @return the base URL that the sling client is pointing to. It should always end with a "/" + */ + public URI getUrl() { + return url; + } + + /** + * @return the user that the client is using. + */ + public String getUser() { + return user; + } + + /** + * @return the user that the client is using. + */ + public String getPassword() { + return password; + } + + /** + * <p>Get the map of extra custom values configured on the client</p> + * <p>These may be used by interceptors, for example</p> + * + * @return the reference to the map + */ + public Map<String, String> getValues() { + return values; + } + + /** + * @return a reference to the cookie store used by the client + */ + public CookieStore getCookieStore() { + return cookieStore; + } + + /** + * @return the reference to the CredentialsProvider used by the client + */ + public CredentialsProvider getCredsProvider() { + return credsProvider; + } + + /** + * @return the reference the AuthCache used by the client + */ + public AuthCache getAuthCache() { + return authCache; + } + + public static class Builder { + protected URI url; + + protected String user; + + protected String password; + + protected CookieStore cookieStore; + + protected CredentialsProvider credsProvider; + + protected AuthCache authCache; + + protected boolean preemptiveAuth = true; + + protected Builder() { + } + + public static Builder create() { + return new Builder(); + } + + public Builder setUrl(String url) throws URISyntaxException { + return setUrl(new URI(url)); + } + + public Builder setUrl(URI url) { + this.url = url; + // Add / as path if none is present + if (Strings.isNullOrEmpty(this.url.getPath()) || !this.url.getPath().endsWith("/")) { + this.url = this.url.resolve(Strings.nullToEmpty(this.url.getPath()) + "/"); + } + return this; + } + + public Builder setUser(String user) { + this.user = user; + return this; + } + + public Builder setPassword(String password) { + this.password = password; + return this; + } + + public Builder setCredentialsProvider(CredentialsProvider credsProvider) { + this.credsProvider = credsProvider; + return this; + } + + public Builder setAuthCache(AuthCache authCache) { + this.authCache = authCache; + return this; + } + + public Builder setPreemptiveAuth(boolean preemptiveAuth) { + this.preemptiveAuth = preemptiveAuth; + return this; + } + + public Builder setCookieStore(CookieStore cookieStore) { + this.cookieStore = cookieStore; + return this; + } + + public SlingClientConfig build() throws ClientException { + if (!this.url.isAbsolute()) { + throw new ClientException("Url must be absolute: " + url); + } + + HttpHost targetHost = URIUtils.extractHost(this.url); + if (targetHost == null) { + throw new ClientException("Failed to extract hostname from url " + url); + } + + // Create default CredentialsProvider if not set + if (credsProvider == null) { + credsProvider = new BasicCredentialsProvider(); + if (StringUtils.isNotEmpty(this.user)) { + credsProvider.setCredentials(new AuthScope(targetHost.getHostName(), targetHost.getPort()), + new UsernamePasswordCredentials(this.user, this.password)); + } + } + + // Create default AuthCache for basic if not set + if (authCache == null) { + BasicScheme basicScheme = new BasicScheme(); + authCache = new BasicAuthCache(); + authCache.put(targetHost, basicScheme); + } + + // if preemptive auth is disabled, force auth cache to be null + if (!this.preemptiveAuth) { + authCache = null; + } + + // Create default CookieStore if not set + if (cookieStore == null) { + cookieStore = new BasicCookieStore(); + } + + return new SlingClientConfig(url, user, password, cookieStore, credsProvider, authCache); + } + } +} Added: release/sling/src/main/java/org/apache/sling/testing/clients/SlingHttpResponse.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/SlingHttpResponse.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/SlingHttpResponse.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,398 @@ +/* + * 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.http.*; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.params.HttpParams; +import org.apache.http.util.EntityUtils; + +import java.io.IOException; +import java.util.*; +import java.util.regex.Pattern; + +public class SlingHttpResponse implements CloseableHttpResponse { + + public static final String STATUS = "Status"; + public static final String MESSAGE = "Message"; + public static final String LOCATION = "Location"; + public static final String PARENT_LOCATION = "ParentLocation"; + public static final String PATH = "Path"; + public static final String REFERER = "Referer"; + public static final String CHANGE_LOG = "ChangeLog"; + + private final CloseableHttpResponse httpResponse; + private String content; + + public SlingHttpResponse(CloseableHttpResponse response) { + this.httpResponse = response; + } + + /** + * <p>Get the {@code String} content of the response.</p> + * <p>The content is cached so it is safe to call this method several times.</p> + * <p><b>Attention!</b> Calling this method consumes the entity, so it cannot be used as an InputStream later</p> + * + * @return the content as String + */ + public String getContent() { + if (!this.isConsumed()) { + try { + this.content = EntityUtils.toString(this.getEntity()); + this.close(); + } catch (IOException e) { + throw new RuntimeException("Could not read content from response", e); + } + } + + return content; + } + + public boolean isConsumed() { + return this.content != null || this.getEntity() == null; + } + + /** + * <p>Assert that response matches supplied status</p> + * + * @param expected the expected http status + * @throws ClientException if the response does not match the expected + */ + public void checkStatus(int expected) throws ClientException { + if (this.getStatusLine().getStatusCode() != expected) { + throw new ClientException(this + " has wrong response status (" + + this.getStatusLine().getStatusCode() + "). Expected " + expected); + } + } + + /** + * <p>Assert that response matches supplied content type (from Content-Type header)</p> + * + * @param expected the expected content type + * @throws ClientException if the response content type does not match the expected + */ + public void checkContentType(String expected) throws ClientException { + // Remove whatever follows semicolon in content-type + String contentType = this.getEntity().getContentType().getValue(); + if (contentType != null) { + contentType = contentType.split(";")[0].trim(); + } + + // check for match + if (!contentType.equals(expected)) { + throw new ClientException(this + " has wrong content type (" + contentType + "). Expected " + expected); + } + } + + /** + * <p>For each regular expression, assert that at least one line of the response matches the expression</p> + * <p>The regular expressions are automatically prefixed and suffixed with .* it order to partial-match the lines</p> + * + * @param regexp list of regular expressions + * @throws ClientException if the response content does not match one of the regexp + */ + public void checkContentRegexp(String... regexp) throws ClientException { + for(String expr : regexp) { + final Pattern p = Pattern.compile(".*" + expr + ".*"); + final Scanner scanner = new Scanner(this.getContent()); + boolean matched = false; + while (scanner.hasNextLine()) { + String line = scanner.nextLine(); + if (p.matcher(line).matches()) { + matched = true; + break; + } + } + + if (!matched) { + throw new ClientException("Pattern " + p + " didn't match any line in content"); + } + } + } + + /** + * <p>Assert that all the provided {@code Strings} are contained in the response</p> + * + * @param expected list of expected strings + * @throws ClientException @throws ClientException if the response content does not match one of the strings + */ + public void checkContentContains(String... expected) throws ClientException { + for (String s : expected) { + if (!this.getContent().contains(s)) { + throw new ClientException("Content does not contain string " + s + ". Content is: \n\n" + getContent()); + } + } + } + + /** + * Get status from Sling Response + * + * @return Sling Status + */ + public String getSlingStatus() { + String searchPattern = "id=\"" + STATUS + "\">"; + return extractFromHTMLResponse(searchPattern); + } + + /** + * Get status from Sling Response as integer + * + * @return Sling Status + * @throws NumberFormatException if sling status can't be parsed as a number + */ + public int getSlingStatusAsInt() throws NumberFormatException { + String strStatus = getSlingStatus(); + return Integer.parseInt(strStatus); + } + + /** + * Get message from Sling Response + * + * @return Sling Message + */ + public String getSlingMessage() { + String searchPattern = "id=\"" + MESSAGE + "\">"; + return extractFromHTMLResponse(searchPattern); + } + + /** + * Get copy paths from message + * + * @return copy paths as String Array + */ + public String[] getSlingCopyPaths() { + String copyPaths = getSlingMessage(); + StringTokenizer tokenizer = new StringTokenizer(copyPaths); + List<String> copies = new ArrayList<String>(); + while (tokenizer.hasMoreElements()) { + copies.add(tokenizer.nextToken()); + } + return copies.toArray(new String[copies.size()]); + } + + /** + * Get location from Sling Response + * + * @return Sling Location + */ + public String getSlingLocation() { + String searchPattern = "id=\"" + LOCATION + "\">"; + return extractFromHTMLResponse(searchPattern); + } + + /** + * Get parent location from Sling Response + * + * @return Sling Parent Location + */ + public String getSlingParentLocation() { + String searchPattern = "id=\"" + PARENT_LOCATION + "\">"; + return extractFromHTMLResponse(searchPattern); + } + + /** + * Get path from Sling Response + * + * @return Sling Path + */ + public String getSlingPath() { + String searchPattern = "id=\"" + PATH + "\">"; + return extractFromHTMLResponse(searchPattern); + } + + /** + * Get referer from Sling Response + * + * @return Sling Referer + */ + public String getSlingReferer() { + String searchPattern = "id=\"" + REFERER + "\">"; + return extractFromHTMLResponse(searchPattern); + } + + /** + * Get change log from Sling Response + * + * @return Sling Change Log + */ + public String getSlingChangeLog() { + String searchPattern = "id=\"" + CHANGE_LOG + "\">"; + return extractFromHTMLResponse(searchPattern); + } + + /** + * Extract information from response + * + * @param searchPattern search pattern to look for + * @return Sling information + */ + protected String extractFromHTMLResponse(String searchPattern) { + String tmpResponse = null; + int start = getContent().indexOf(searchPattern); + if (start > 0) { + start += searchPattern.length(); + tmpResponse = getContent().substring(start); + int end = tmpResponse.indexOf("<"); + tmpResponse = tmpResponse.substring(0, end); + } + return tmpResponse; + } + + // HttpResponse delegated methods + + @Override + public StatusLine getStatusLine() { + return httpResponse.getStatusLine(); + } + + @Override + public void setStatusLine(StatusLine statusline) { + httpResponse.setStatusLine(statusline); + } + + @Override + public void setStatusLine(ProtocolVersion ver, int code) { + httpResponse.setStatusLine(ver, code); + } + + @Override + public void setStatusLine(ProtocolVersion ver, int code, String reason) { + httpResponse.setStatusLine(ver, code, reason); + } + + @Override + public void setStatusCode(int code) throws IllegalStateException { + httpResponse.setStatusCode(code); + } + + @Override + public void setReasonPhrase(String reason) throws IllegalStateException { + httpResponse.setReasonPhrase(reason); + } + + @Override + public HttpEntity getEntity() { + return httpResponse.getEntity(); + } + + @Override + public void setEntity(HttpEntity entity) { + httpResponse.setEntity(entity); + } + + @Override + public Locale getLocale() { + return httpResponse.getLocale(); + } + + @Override + public void setLocale(Locale loc) { + httpResponse.setLocale(loc); + } + + @Override + public ProtocolVersion getProtocolVersion() { + return httpResponse.getProtocolVersion(); + } + + @Override + public boolean containsHeader(String name) { + return httpResponse.containsHeader(name); + } + + @Override + public Header[] getHeaders(String name) { + return httpResponse.getHeaders(name); + } + + @Override + public Header getFirstHeader(String name) { + return httpResponse.getFirstHeader(name); + } + + @Override + public Header getLastHeader(String name) { + return httpResponse.getLastHeader(name); + } + + @Override + public Header[] getAllHeaders() { + return httpResponse.getAllHeaders(); + } + + @Override + public void addHeader(Header header) { + httpResponse.addHeader(header); + } + + @Override + public void addHeader(String name, String value) { + httpResponse.addHeader(name, value); + } + + @Override + public void setHeader(Header header) { + httpResponse.setHeader(header); + } + + @Override + public void setHeader(String name, String value) { + httpResponse.setHeader(name, value); + } + + @Override + public void setHeaders(Header[] headers) { + httpResponse.setHeaders(headers); + } + + @Override + public void removeHeader(Header header) { + httpResponse.removeHeader(header); + } + + @Override + public void removeHeaders(String name) { + httpResponse.removeHeaders(name); + } + + @Override + public HeaderIterator headerIterator() { + return httpResponse.headerIterator(); + } + + @Override + public HeaderIterator headerIterator(String name) { + return httpResponse.headerIterator(name); + } + + @SuppressWarnings("deprecation") + @Override + public HttpParams getParams() { + return httpResponse.getParams(); + } + + @SuppressWarnings("deprecation") + @Override + public void setParams(HttpParams params) { + httpResponse.setParams(params); + } + + @Override + public void close() throws IOException { + httpResponse.close(); + } +} Added: release/sling/src/main/java/org/apache/sling/testing/clients/SystemPropertiesConfig.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/SystemPropertiesConfig.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/SystemPropertiesConfig.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,130 @@ +/* + * 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 java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Objects; +import java.util.stream.Collectors; + +public class SystemPropertiesConfig { + + /** + * Prefix for IT-specific system properties + */ + public static final String CONFIG_PROP_PREFIX = "sling.it."; + + /** + * System property for {@link SystemPropertiesConfig#getHttpDelay()} + * Prefixed by {@link SystemPropertiesConfig#CONFIG_PROP_PREFIX} + */ + public static final String HTTP_DELAY_PROP = "http.delay"; + + /** + * System property for {@link SystemPropertiesConfig#getHttpRetries()} + * Prefixed by {@link SystemPropertiesConfig#CONFIG_PROP_PREFIX} + */ + public static final String HTTP_RETRIES_PROP = "http.retries"; + + /** + * System property for {@link SystemPropertiesConfig#getHttpRetriesDelay()} + * Prefixed by {@link SystemPropertiesConfig#CONFIG_PROP_PREFIX} + */ + public static final String HTTP_RETRIES_DELAY_PROP = "http.retriesDelay"; + + /** + * System property for {@link SystemPropertiesConfig#isHttpLogRetries()} + * Prefixed by {@link SystemPropertiesConfig#CONFIG_PROP_PREFIX} + */ + public static final String HTTP_LOG_RETRIES_PROP = "http.logRetries"; + + /** + * System property for {@link SystemPropertiesConfig#getHttpRetriesErrorCodes()} + * Prefixed by {@link SystemPropertiesConfig#CONFIG_PROP_PREFIX} + */ + public static final String HTTP_RETRIES_ERROR_CODES_PROP = "http.retriesErrorCodes"; + + public static String getPrefixedPropertyName(String prop) { + return SystemPropertiesConfig.CONFIG_PROP_PREFIX + prop; + } + + /** + * Custom delay in milliseconds before an HTTP request goes through. + * Used by {@link org.apache.sling.testing.clients.interceptors.DelayRequestInterceptor} + */ + public static long getHttpDelay() { + try { + return Long.getLong(getPrefixedPropertyName(HTTP_DELAY_PROP), 0); + } catch (Exception e) { + return 0; + } + } + + /** + * Number of http call retries in case of a 5XX response code + */ + public static int getHttpRetries() { + try { + return Integer.getInteger(getPrefixedPropertyName(HTTP_RETRIES_PROP), 10); + } catch (Exception e) { + return 0; + } + } + + /** + * The delay in milliseconds between http retries + */ + public static int getHttpRetriesDelay() { + try { + return Integer.getInteger(getPrefixedPropertyName(HTTP_RETRIES_DELAY_PROP), 1000); + } catch (Exception e) { + return 0; + } + } + + /** + * Whether to log or not http request retries + */ + public static boolean isHttpLogRetries() { + try { + return Boolean.getBoolean(getPrefixedPropertyName(HTTP_LOG_RETRIES_PROP)); + } catch (Exception e) { + return false; + } + } + + /** + * Comma-separated list of http response codes for which to retry the request + * If empty, all 5XX error codes will be retried + */ + public static Collection<Integer> getHttpRetriesErrorCodes() { + try { + final String errorCodes = System.getProperty(getPrefixedPropertyName(HTTP_RETRIES_ERROR_CODES_PROP), ""); + return Arrays.asList(errorCodes.split(",")).stream().map(s -> { + try { + return Integer.valueOf(s); + } catch (NumberFormatException e) { + return null; + } + }).filter(Objects::nonNull).collect(Collectors.toList()); + } catch (Exception e) { + return Collections.emptyList(); + } + } + +} Added: release/sling/src/main/java/org/apache/sling/testing/clients/email/EmailMessage.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/email/EmailMessage.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/email/EmailMessage.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,80 @@ +/* + * 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.email; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Holds information retrieved from the mock SMTP server deployed in Sling + * + */ +public final class EmailMessage { + + public static final String HEADER_FROM = "From"; + public static final String HEADER_TO = "To"; + public static final String HEADER_SUBJECT = "Subject"; + + private Map<String, String> headers = new LinkedHashMap<>(); + + private String content; + + public EmailMessage(String content) { + this.content = content; + } + + /** + * Adds a new header to this email message + * + * @param key the header name + * @param value the header value + */ + public void addHeader(String key, String value) { + headers.put(key, value); + } + + /** + * Returns the value of one of the headers of this email + * + * @param key the header name + * @return the value of the header, possibly <code>null</code> + */ + public String getHeader(String key) { + return headers.get(key); + } + + /** + * Returns an unmodifiable view over the email headers + * + * @return the headers + */ + public Map<String, String> getHeaders() { + return Collections.unmodifiableMap(headers); + } + + /** + * Returns the contents of the email + * + * @return the email content + */ + public String getContent() { + return content; + } +} \ No newline at end of file Added: release/sling/src/main/java/org/apache/sling/testing/clients/email/SlingEmailClient.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/email/SlingEmailClient.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/email/SlingEmailClient.java Thu Apr 9 13:50:42 2020 @@ -0,0 +1,123 @@ +/* + * 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.email; + +import static org.apache.http.HttpStatus.SC_NO_CONTENT; +import static org.apache.http.HttpStatus.SC_OK; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import org.apache.http.Header; +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.codehaus.jackson.JsonNode; +import org.codehaus.jackson.map.ObjectMapper; + +/** + * Accesses email stored by a mock SMTP server deployed to Sling + * + * <p>Requires that the <code>org.apache.sling.testing.email</code> bundle is deployed.</p> + */ +public final class SlingEmailClient extends SlingClient { + + /** + * The well-known path under which the EmailServlet is deployed + */ + private static final String EMAIL_SERVLET_PATH = "/system/sling/testing/email"; + + /** + * The well-known property name of the email body contents + */ + private static final String PN_CONTENT = "-Content-"; + + + private final ObjectMapper mapper = new ObjectMapper(); + + public SlingEmailClient(CloseableHttpClient http, SlingClientConfig config) throws ClientException { + super(http, config); + } + + /** + * Retrieves the actual bind port of the SMTP server + * + * @return the port value + * @throws ClientException in case of any errors + */ + public int getBindPort() throws ClientException { + try { + SlingHttpResponse mockEmailConfig = doGet(EMAIL_SERVLET_PATH + "/config", SC_OK); + + JsonNode configNode = mapper.readTree(mockEmailConfig.getContent()); + return configNode.get("bindPort").getIntValue(); + } catch (IOException e) { + throw new ClientException("Failed retrieving configuration", e); + } + } + + /** + * Retrieves the list of mail messages currently stored + * + * @return the list of messages, possibly empty + * @throws ClientException in case of any errors + */ + public List<EmailMessage> getMessages() throws ClientException { + List<EmailMessage> emails = new ArrayList<>(); + + try { + SlingHttpResponse response = doGet(EMAIL_SERVLET_PATH + "/messages", SC_OK); + JsonNode messages = mapper.readTree(response.getContent()); + for ( JsonNode emailNode : messages.get("messages") ) { + EmailMessage msg = new EmailMessage(emailNode.get(PN_CONTENT).getTextValue()); + Iterator<String> fieldNames = emailNode.getFieldNames(); + while ( fieldNames.hasNext() ) { + String fieldName = fieldNames.next(); + if ( fieldName.equals(PN_CONTENT) ) { + continue; + } + msg.addHeader(fieldName, emailNode.get(fieldName).getTextValue()); + } + + emails.add(msg); + } + } catch (IOException e) { + throw new ClientException("Failed retrieving email messages", e); + } + + + return emails; + } + + /** + * Deletes all mail messages currently stored + * + * @throws ClientException in case of any errors + */ + public void deleteMessages() throws ClientException { + doDelete(EMAIL_SERVLET_PATH, Collections.<NameValuePair>emptyList(), + Collections.<Header> emptyList(), SC_NO_CONTENT); + } +} Added: release/sling/src/main/java/org/apache/sling/testing/clients/email/package-info.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/email/package-info.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/email/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("1.2.0") +package org.apache.sling.testing.clients.email; + +import org.osgi.annotation.versioning.Version; Added: release/sling/src/main/java/org/apache/sling/testing/clients/html/MicrodataClient.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/html/MicrodataClient.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/html/MicrodataClient.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.html; + +import java.net.URI; + +import org.apache.http.HttpEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.sling.hapi.client.HtmlClient; +import org.apache.sling.hapi.client.impl.microdata.MicrodataDocument; +import org.apache.sling.testing.clients.ClientException; +import org.apache.sling.testing.clients.SlingClient; +import org.apache.sling.testing.clients.SlingClientConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MicrodataClient extends SlingClient implements HtmlClient { + protected static final Logger LOG = LoggerFactory.getLogger(MicrodataClient.class); + + public MicrodataClient(CloseableHttpClient http, SlingClientConfig config) throws ClientException { + super(http, config); + } + + public MicrodataClient(URI url, String user, String password) throws ClientException { + super(url, user, password); + } + + @Override + public MicrodataDocument enter(String url) throws org.apache.sling.hapi.client.ClientException { + return get(url); + } + + @Override + public MicrodataDocument get(String url) throws org.apache.sling.hapi.client.ClientException { + try { + return newDocument(doGet(url).getContent()); + } catch (ClientException e) { + throw new org.apache.sling.hapi.client.ClientException("Cannot create Microdata document", e); + } + } + + @Override + public MicrodataDocument post(String url, HttpEntity entity) throws org.apache.sling.hapi.client.ClientException { + try { + return newDocument(doPost(url, entity).getContent()); + } catch (ClientException e) { + throw new org.apache.sling.hapi.client.ClientException("Cannot create Microdata document", e); + } + } + + @Override + public MicrodataDocument delete(String url) throws org.apache.sling.hapi.client.ClientException { + try { + return newDocument(doDelete(url, null, null).getContent()); + } catch (ClientException e) { + throw new org.apache.sling.hapi.client.ClientException("Cannot create Microdata document", e); + } + } + + @Override + public MicrodataDocument newDocument(String html) { + return new MicrodataDocument(html, this, this.getUrl().toString()); + } + +} Added: release/sling/src/main/java/org/apache/sling/testing/clients/html/package-info.java ============================================================================== --- release/sling/src/main/java/org/apache/sling/testing/clients/html/package-info.java (added) +++ release/sling/src/main/java/org/apache/sling/testing/clients/html/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.3.0") +package org.apache.sling.testing.clients.html; + +import org.osgi.annotation.versioning.Version;
