diff --git a/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/bnd.bnd b/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/bnd.bnd index 8d76c105a..9a9e9cbc6 100644 --- a/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/bnd.bnd +++ b/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/bnd.bnd @@ -17,16 +17,20 @@ Import-Package: com.google.gson,\ org.apache.commons.logging,\ org.apache.hc.core5.http,\ org.apache.hc.core5.http.io.entity,\ + org.apache.hc.core5.http.io.support,\ org.apache.hc.core5.http.message,\ org.apache.hc.core5.http.protocol,\ + org.apache.hc.core5.http.ssl,\ org.apache.hc.core5.net,\ org.apache.hc.core5.util,\ + org.apache.hc.client5.http,\ org.apache.hc.client5.http.auth,\ org.apache.hc.client5.http.classic.methods,\ org.apache.hc.client5.http.config,\ org.apache.hc.client5.http.cookie,\ org.apache.hc.client5.http.entity,\ org.apache.hc.client5.http.entity.mime,\ + org.apache.hc.client5.http.impl,\ org.apache.hc.client5.http.impl.auth,\ org.apache.hc.client5.http.impl.classic,\ org.apache.hc.client5.http.impl.io,\ diff --git a/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/ContentType.java b/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/ContentType.java index f104a402b..7eeae5d79 100644 --- a/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/ContentType.java +++ b/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/ContentType.java @@ -18,6 +18,8 @@ public enum ContentType { APPLICATION_FORM_URLENCODED(org.apache.hc.core5.http.ContentType.APPLICATION_FORM_URLENCODED), TEXT_PLAIN(org.apache.hc.core5.http.ContentType.TEXT_PLAIN), TEXT_HTML(org.apache.hc.core5.http.ContentType.TEXT_HTML), + TEXT_MARKDOWN(org.apache.hc.core5.http.ContentType.TEXT_MARKDOWN), + TEXT_EVENT_STREAM(org.apache.hc.core5.http.ContentType.TEXT_EVENT_STREAM), TEXT_XML(org.apache.hc.core5.http.ContentType.TEXT_XML), RDF_XML("application/rdf+xml"), SOAP_XML("application/soap+xml"); diff --git a/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/HttpClient.java b/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/HttpClient.java index 2a623fbef..f957b1693 100644 --- a/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/HttpClient.java +++ b/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/HttpClient.java @@ -26,4 +26,8 @@ @ValidAnnotatedFields({ IHttpClient.class }) public @interface HttpClient { + /** + * The length of time a connection or response will timeout (in milliseconds). Optional. + */ + int timeout() default 0; } diff --git a/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/IHttpClient.java b/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/IHttpClient.java index cce5a29e0..b628b0c83 100644 --- a/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/IHttpClient.java +++ b/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/IHttpClient.java @@ -207,6 +207,28 @@ HttpClientResponse postXML(String url, String xml) */ HttpClientResponse postText(String url, String text) throws HttpClientException; + /** + * Issue an HTTP POST to the provided URL, sending form-encoded data + * and receiving a {@link String} in the response. + * + *

+ * This is a convenience method for posting form data (e.g., login credentials). + * The data is encoded as application/x-www-form-urlencoded, which is the standard + * format for HTML form submissions. + *

+ * + *

+ * This method is commonly used for form-based authentication where credentials + * are posted as form fields (e.g., j_username, j_password for Java EE security). + *

+ * + * @param url - URL path + * @param fields - Form fields as key-value pairs + * @return - {@link HttpClientResponse} with a {@link String} content type + * @throws HttpClientException if the request fails + */ + HttpClientResponse postForm(String url, Map fields) throws HttpClientException; + /** * Issue an HTTP PUT to the provided URL, sending the provided {@link String} * and receiving a {@link String} in the response. @@ -412,6 +434,24 @@ Object postForm(String path, Map queryParams, HashMap commonHeaders = new ArrayList<>(); + private TLS[] tlsVersions; private final int timeout; + private Timeout socketTimeout = null; + private Timeout connectTimeout = null; private BasicCookieStore cookieStore; private SSLContext sslContext; @@ -105,7 +126,10 @@ public class HttpClientImpl implements IHttpClient { private Log logger; private SSLTLSContextNameSelector nameSelector = new SSLTLSContextNameSelector(); - + + ContentType[] textContentTypes = new ContentType[] { ContentType.TEXT_PLAIN, ContentType.TEXT_EVENT_STREAM, + ContentType.TEXT_HTML, ContentType.TEXT_MARKDOWN, ContentType.TEXT_XML }; + public HttpClientImpl(int timeout, Log log) { this(timeout, log, new HttpRequestExecutor()); } @@ -114,6 +138,7 @@ public HttpClientImpl(int timeout, Log log, IHttpRequestExecutor httpRequestExec this.timeout = timeout; this.logger = log; this.cookieStore = new BasicCookieStore(); + this.tlsVersions = new TLS[] { TLS.v1_2 }; this.httpRequestExecutor = httpRequestExecutor; } @@ -256,7 +281,7 @@ private HttpClientResponse executeJsonRequest(HttpClientRequest requ public HttpClientResponse getText(String url) throws HttpClientException { HttpClientRequest request = HttpClientRequest.newGetRequest(buildUri(url, null).toString(), - new ContentType[] { ContentType.TEXT_PLAIN }); + textContentTypes); return executeTextRequest(request); } @@ -285,7 +310,7 @@ public HttpClientResponse postXML(String url, String xml) throws HttpCli public HttpClientResponse putSOAP(String url, String xml) throws HttpClientException { HttpClientRequest request = HttpClientRequest.newPutRequest(buildUri(url, null).toString(), - new ContentType[] { ContentType.SOAP_XML }, ContentType.SOAP_XML); + new ContentType[] { ContentType.SOAP_XML }, ContentType.SOAP_XML); request.setBody(xml); return executeTextRequest(request); @@ -295,7 +320,7 @@ public HttpClientResponse putSOAP(String url, String xml) throws HttpCli public HttpClientResponse postSOAP(String url, String xml) throws HttpClientException { HttpClientRequest request = HttpClientRequest.newPostRequest(buildUri(url, null).toString(), - new ContentType[] { ContentType.SOAP_XML }, ContentType.SOAP_XML); + new ContentType[] { ContentType.SOAP_XML }, ContentType.SOAP_XML); request.setBody(xml); return executeTextRequest(request); @@ -305,7 +330,7 @@ public HttpClientResponse postSOAP(String url, String xml) throws HttpCl public HttpClientResponse putText(String url, String text) throws HttpClientException { HttpClientRequest request = HttpClientRequest.newPutRequest(buildUri(url, null).toString(), - new ContentType[] { ContentType.TEXT_PLAIN }, ContentType.TEXT_PLAIN); + textContentTypes, ContentType.TEXT_PLAIN); request.setBody(text); return executeTextRequest(request); @@ -315,17 +340,25 @@ public HttpClientResponse putText(String url, String text) throws HttpCl public HttpClientResponse postText(String url, String text) throws HttpClientException { HttpClientRequest request = HttpClientRequest.newPostRequest(buildUri(url, null).toString(), - new ContentType[] { ContentType.TEXT_PLAIN }, ContentType.TEXT_PLAIN); + textContentTypes, ContentType.TEXT_PLAIN); request.setBody(text); return executeTextRequest(request); } + @Override + public HttpClientResponse postForm(String url, Map fields) throws HttpClientException { + HttpClientRequest request = HttpClientRequest.newPostRequest(buildUri(url, null).toString(), + textContentTypes, ContentType.APPLICATION_FORM_URLENCODED); + request.setFormBody(fields); + return executeTextRequest(request); + } + @Override public HttpClientResponse deleteText(String url) throws HttpClientException { HttpClientRequest request = HttpClientRequest.newDeleteRequest(buildUri(url, null).toString(), - new ContentType[] { ContentType.TEXT_PLAIN }); + textContentTypes); return executeTextRequest(request); } @@ -621,6 +654,28 @@ public IHttpClient setAuthorisation(String username, String password, URI scope) return this; } + /** + * Set the socket timeout in seconds. + * This is the timeout for waiting for data after connection is established. + * + * @param seconds - timeout in seconds + */ + @Override + public void setSocketTimeout(int seconds) { + this.socketTimeout = Timeout.ofSeconds(seconds); + } + + /** + * Set the connection timeout in seconds. + * This is the timeout for establishing the connection. + * + * @param seconds - timeout in seconds + */ + @Override + public void setConnectTimeout(int seconds) { + this.connectTimeout = Timeout.ofSeconds(seconds); + } + /** * Build the client * @@ -630,25 +685,57 @@ public IHttpClient build() { RequestConfig.Builder requestBuilder = RequestConfig.custom(); HttpClientBuilder builder = HttpClientBuilder.create(); builder.setDefaultCookieStore(cookieStore); + builder.setRedirectStrategy(new BaseUriRedirectStrategy()); requestBuilder.setCookieSpec(StandardCookieSpec.STRICT); + requestBuilder.setRedirectsEnabled(true); + requestBuilder.setCircularRedirectsAllowed(true); builder.setDefaultCredentialsProvider(credentialsProvider); builder.setDefaultHeaders(commonHeaders); + // Apply request-level timeouts if default timeout is set if (timeout > 0) { Timeout timeoutValue = Timeout.ofMilliseconds(timeout); requestBuilder.setConnectionRequestTimeout(timeoutValue) .setResponseTimeout(timeoutValue); } + // Create connection manager and apply connection-level timeouts + PoolingHttpClientConnectionManagerBuilder cmBuilder = PoolingHttpClientConnectionManagerBuilder.create(); + if (sslContext != null) { - TlsSocketStrategy tlsStrategy = new DefaultClientTlsStrategy(sslContext, hostnameVerifier); - HttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create() - .setTlsSocketStrategy(tlsStrategy) - .build(); - builder.setConnectionManager(cm); + org.apache.hc.core5.http.ssl.TLS [] tlsApacheVersions = Arrays.asList(tlsVersions).stream() + .map(t -> t.getTls()) + .collect(Collectors.toList()) + .toArray(org.apache.hc.core5.http.ssl.TLS[]::new); + + TlsSocketStrategy tlsStrategy = ClientTlsStrategyBuilder.create().setSslContext(sslContext) + .setTlsVersions(tlsApacheVersions).setHostnameVerifier(hostnameVerifier).buildClassic(); + cmBuilder.setTlsSocketStrategy(tlsStrategy); + } + + PoolingHttpClientConnectionManager connectionManager = cmBuilder.build(); + + // Apply connection-level timeouts (socket and connect) if set + if (socketTimeout != null || connectTimeout != null) { + ConnectionConfig.Builder connConfigBuilder = ConnectionConfig.custom(); + + if (socketTimeout != null) { + connConfigBuilder.setSocketTimeout(socketTimeout); + } + + if (connectTimeout != null) { + connConfigBuilder.setConnectTimeout(connectTimeout); + } + + connectionManager.setDefaultConnectionConfig(connConfigBuilder.build()); } + + builder.setConnectionManager(connectionManager); builder.setDefaultRequestConfig(requestBuilder.build()); httpClient = builder.build(); + + httpContext = ContextBuilder.create().useCookieStore(cookieStore).useCredentialsProvider(credentialsProvider) + .build(); return this; } @@ -806,7 +893,7 @@ private void appendPath(URIBuilder ub, String path) { return; } - if (!path.startsWith("/")) { + if (!path.startsWith("/") && !ub.toString().endsWith("/")) { path = "/" + path; } @@ -967,4 +1054,41 @@ public void close() { } + @Override + public void clearCookies() { + this.cookieStore.clear(); + } + + @Override + public void setTlsVersions(TLS[] tlsVersions) { + this.tlsVersions = tlsVersions; + } + + public TLS[] getTlsVersions() { + return tlsVersions; + } + + /** + * Custom redirect strategy that resolves relative redirect URIs against the base URI. + * This ensures that redirects like "/" are resolved as "/myapp/" instead of just "/". + */ + private class BaseUriRedirectStrategy extends DefaultRedirectStrategy { + @Override + public URI getLocationURI(HttpRequest request, HttpResponse response, HttpContext context) + throws org.apache.hc.core5.http.HttpException { + // Get the redirect location from the response + URI locationUri = super.getLocationURI(request, response, context); + + if (locationUri.toString().replace(HttpClientImpl.this.host.toString(), "").trim().isBlank()) { + try { + locationUri = new URI(locationUri.toString() + "/"); + } + catch (URISyntaxException e) { + return locationUri; + } + } + + return locationUri; + } + } } diff --git a/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/internal/HttpClientRequest.java b/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/internal/HttpClientRequest.java index dd7095ecb..427e4236b 100644 --- a/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/internal/HttpClientRequest.java +++ b/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/internal/HttpClientRequest.java @@ -10,7 +10,9 @@ import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -21,12 +23,15 @@ import org.apache.hc.client5.http.classic.methods.HttpPut; import org.apache.hc.client5.http.classic.methods.HttpUriRequest; import org.apache.hc.client5.http.classic.methods.HttpPatch; +import org.apache.hc.client5.http.entity.UrlEncodedFormEntity; import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.io.entity.ByteArrayEntity; import org.apache.hc.core5.http.io.entity.FileEntity; import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicNameValuePair; import org.apache.hc.core5.net.URIBuilder; import org.w3c.dom.Document; @@ -208,6 +213,25 @@ public HttpClientRequest setJSONBody(JsonObject json) { return setBody(json.toString()); } + /** + * Set the body of the request as form-encoded data. + * + *

+ * This method encodes the provided fields as application/x-www-form-urlencoded + * data, which is commonly used for HTML form submissions (e.g., login forms). + * + * @param fields - Map of form field names to values + * @return - the updated request + */ + public HttpClientRequest setFormBody(Map fields) { + List nvps = new ArrayList<>(); + for (Entry field : fields.entrySet()) { + nvps.add(new BasicNameValuePair(field.getKey(), field.getValue())); + } + this.content = new UrlEncodedFormEntity(nvps); + return this; + } + /** * Set the body of the request * diff --git a/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/internal/HttpManagerImpl.java b/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/internal/HttpManagerImpl.java index 550050246..b299a8646 100644 --- a/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/internal/HttpManagerImpl.java +++ b/modules/managers/galasa-managers-parent/galasa-managers-comms-parent/dev.galasa.http.manager/src/main/java/dev/galasa/http/internal/HttpManagerImpl.java @@ -36,7 +36,18 @@ public class HttpManagerImpl extends AbstractManager implements IHttpManagerSpi @GenerateAnnotatedField(annotation = HttpClient.class) public IHttpClient generateHttpClient(Field field, List annotations) { - return newHttpClient(); + HttpClient annotationHttpClient = field.getAnnotation(HttpClient.class); + + int timeout = annotationHttpClient.timeout(); + + IHttpClient httpClient = null; + if (timeout > 0) { + httpClient = newHttpClient(timeout); + } + else { + httpClient = newHttpClient(); + } + return httpClient; } @Override