no.mnemonic.services.triggers.action.HttpClientAction Maven / Gradle / Ivy
package no.mnemonic.services.triggers.action;
import no.mnemonic.commons.logging.Logger;
import no.mnemonic.commons.logging.Logging;
import no.mnemonic.commons.utilities.collections.MapUtils;
import no.mnemonic.commons.utilities.collections.SetUtils;
import no.mnemonic.commons.utilities.lambda.LambdaUtils;
import no.mnemonic.services.triggers.action.exceptions.ParameterException;
import no.mnemonic.services.triggers.action.exceptions.TriggerExecutionException;
import no.mnemonic.services.triggers.action.exceptions.TriggerInitializationException;
import org.apache.hc.client5.http.HttpResponseException;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.*;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
import org.apache.hc.core5.http.message.StatusLine;
import org.apache.hc.core5.io.CloseMode;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
/**
* {@link TriggerAction} implementation for calling HTTP(s) webhooks.
*
* It has the following initialization parameters:
*
* - proxy: URL of a server which will be used to proxy requests (optional).
* - All other parameters will be ignored.
*
*
* It has the following trigger parameters:
*
* - url: URL of the webhook to call (required).
* - method: HTTP method to be used when making requests (optional, defaults to GET).
* - body: Body send in request (optional).
* - contentType: Media type of body data (optional, defaults to text/plain, ignored if body parameter is not specified).
* - Parameters starting with the prefix header@ will be sent as additional request headers (without the prefix).
* - All other parameters will be ignored.
*
*/
public class HttpClientAction implements TriggerAction {
private static final Logger LOGGER = Logging.getLogger(HttpClientAction.class);
private static final String INIT_PARAMETER_PROXY = "proxy";
private static final String TRIGGER_PARAMETER_METHOD = "method";
private static final String TRIGGER_PARAMETER_URL = "url";
private static final String TRIGGER_PARAMETER_BODY = "body";
private static final String TRIGGER_PARAMETER_CONTENT_TYPE = "contentType";
private static final String TRIGGER_PARAMETER_HEADER_PREFIX = "header@";
private static final Set SUPPORTED_PROTOCOLS = Collections.unmodifiableSet(SetUtils.set("http", "https"));
private CloseableHttpClient client;
@Override
public void init(Map initParameters) throws ParameterException, TriggerInitializationException {
// Copy initialization parameters into an internal variable, such that it's safe to change them.
Map params = MapUtils.map(initParameters);
try {
// Create HTTP client by applying provided initialization parameters and system properties as a fallback.
client = applyProxySettings(HttpClients.custom(), params)
.useSystemProperties()
.build();
} catch (ParameterException ex) {
// If applying initialization parameters throws a ParameterException just log and re-throw it.
LOGGER.warning(ex, "Could not initialize HTTP client. Parameter '%s' is invalid", ex.getParameter());
throw ex;
} catch (Exception catchAll) {
// All other exceptions are treated as action initialization failed.
LOGGER.error(catchAll, "Could not initialize HTTP client.");
throw new TriggerInitializationException("Could not initialize HTTP client.", catchAll);
}
}
@Override
public void trigger(Map triggerParameters) throws ParameterException, TriggerExecutionException {
if (client == null) {
throw new IllegalStateException("Cannot execute action because HTTP client is not initialized. Forgot to call init()?");
}
// Copy trigger parameters into an internal variable, such that it's safe to change them.
Map params = MapUtils.map(triggerParameters);
try {
client.execute(createHttpRequest(params), response -> {
// Everything which is not a 2xx status code is considered an error. Also ignore any response body.
StatusLine statusLine = new StatusLine(response);
int code = statusLine.getStatusCode();
if (!(code >= 200 && code < 300)) {
logResponse(code, response.getEntity());
throw new HttpResponseException(code, statusLine.getReasonPhrase());
}
if (LOGGER.isInfo()) {
LOGGER.info("Successfully executed HTTP request. Received response with status: %s", statusLine);
}
return response;
});
} catch (ParameterException ex) {
// If executing the HTTP request throws a ParameterException just log and re-throw it.
LOGGER.warning(ex, "Could not execute HTTP request. Parameter '%s' is invalid", ex.getParameter());
throw ex;
} catch (Exception catchAll) {
// All other exceptions are treated as action execution failed. Especially this will contain IOExceptions and
// ClientProtocolExceptions thrown by the HTTP client on execute().
LOGGER.error(catchAll, "Could not execute HTTP request.");
throw new TriggerExecutionException("Could not execute HTTP request.", catchAll);
}
}
@Override
public void close() {
client.close(CloseMode.GRACEFUL);
}
private HttpClientBuilder applyProxySettings(HttpClientBuilder builder, Map initParameters)
throws ParameterException {
if (!initParameters.containsKey(INIT_PARAMETER_PROXY)) return builder;
String proxy = initParameters.get(INIT_PARAMETER_PROXY);
try {
HttpHost host = HttpHost.create(proxy);
if (LOGGER.isDebug()) {
LOGGER.debug("Configured HTTP client to use proxy: %s", host.toURI());
}
return builder.setProxy(host);
} catch (Exception ex) {
throw new ParameterException(String.format("Provided proxy '%s' is invalid.", proxy), ex, INIT_PARAMETER_PROXY);
}
}
private ClassicHttpRequest createHttpRequest(Map triggerParameters) throws ParameterException {
Method method = extractMethod(triggerParameters);
URI uri = extractUri(triggerParameters);
String body = triggerParameters.get(TRIGGER_PARAMETER_BODY);
ContentType contentType = extractContentType(triggerParameters);
ClassicRequestBuilder requestBuilder = ClassicRequestBuilder.create(method.name())
.setUri(uri);
// Request body is optional.
if (body != null) {
requestBuilder.setEntity(body, contentType);
}
// All parameters starting with "header@" are considered headers.
for (String parameter : triggerParameters.keySet()) {
if (!parameter.startsWith(TRIGGER_PARAMETER_HEADER_PREFIX)) continue;
requestBuilder.addHeader(parameter.replaceFirst(TRIGGER_PARAMETER_HEADER_PREFIX, ""), triggerParameters.get(parameter));
}
if (LOGGER.isDebug()) {
LOGGER.debug("Created %s request to URL %s with body:%n%s", method, uri, body);
}
return requestBuilder.build();
}
private Method extractMethod(Map triggerParameters) throws ParameterException {
if (!triggerParameters.containsKey(TRIGGER_PARAMETER_METHOD)) return Method.GET;
String method = triggerParameters.get(TRIGGER_PARAMETER_METHOD);
try {
return Method.normalizedValueOf(method);
} catch (Exception ex) {
throw new ParameterException(String.format("Provided method '%s' is invalid.", method), ex, TRIGGER_PARAMETER_METHOD);
}
}
private URI extractUri(Map triggerParameters) throws ParameterException {
if (!triggerParameters.containsKey(TRIGGER_PARAMETER_URL)) {
throw new ParameterException("Required trigger parameter 'url' is missing.", TRIGGER_PARAMETER_URL);
}
String url = triggerParameters.get(TRIGGER_PARAMETER_URL);
try {
// A URL is expected here as a parameter but HttpUriRequest takes a URI, thus, a new URL is constructed first
// to have stricter parsing and is converted to a URI afterwards satisfying HttpUriRequest.
URI result = new URL(url).toURI();
if (!SUPPORTED_PROTOCOLS.contains(result.getScheme())) {
throw new MalformedURLException(String.format("Protocol '%s' is not supported.", result.getScheme()));
}
return result;
} catch (Exception ex) {
throw new ParameterException(String.format("Provided URL '%s' is invalid.", url), ex, TRIGGER_PARAMETER_URL);
}
}
private ContentType extractContentType(Map triggerParameters) throws ParameterException {
if (!triggerParameters.containsKey(TRIGGER_PARAMETER_CONTENT_TYPE)) return ContentType.DEFAULT_TEXT;
String contentType = triggerParameters.get(TRIGGER_PARAMETER_CONTENT_TYPE);
try {
return ContentType.parse(contentType);
} catch (Exception ex) {
throw new ParameterException(String.format("Provided content type '%s' is invalid.", contentType), ex, TRIGGER_PARAMETER_CONTENT_TYPE);
}
}
private void logResponse(int statusCode, HttpEntity responseBody) {
if (!LOGGER.isDebug()) return;
// Just ignore any exceptions, logging the response shouldn't interrupt the ordinary method flow.
LambdaUtils.tryTo(
() -> LOGGER.debug("HTTP request failed with code %d and response:%n%s", statusCode, EntityUtils.toString(responseBody)),
ex -> LOGGER.debug(ex, "Failed to log response.")
);
}
}