/*
* Copyright 2010 Red Hat, Inc. and/or its affiliates.
*
* Licensed 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.jbpm.process.workitem.rest;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.nio.charset.UnsupportedCharsetException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.xml.bind.JAXBContext;
import org.apache.http.Consts;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.StatusLine;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.AuthCache;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.HttpClient;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.client.protocol.ClientContext;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.client.BasicAuthCache;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.params.CoreConnectionPNames;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.util.EntityUtils;
import org.drools.core.util.StringUtils;
import org.jbpm.process.workitem.AbstractLogOrThrowWorkItemHandler;
import org.kie.api.runtime.process.WorkItem;
import org.kie.api.runtime.process.WorkItemManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* WorkItemHandler that is capable of interacting with REST service. Supports both types of services
* secured (that requires authentication) and open (no authentication). Authentication methods currently supported:
* <ul>
* <li>BASIC</li>
* <li>FORM BASED</li>
* </ul>
* Authentication information can be given on handler initialization and can be overridden via work item parameters.
* All other configuration options must be given via work item parameters map:
* <ul>
* <li>Url - resource location to be invoked - mandatory</li>
* <li>Method - HTTP method that will be executed - defaults to GET</li>
* <li>ContentType - data type in case of sending data - mandatory for POST,PUT</li>
* <li>Content - actual data to be sent - mandatory for POST,PUT</li>
* <li>ConnectTimeout - connection time out - default to 60 seconds</li>
* <li>ReadTimeout - read time out - default to 60 seconds</li>
* <li>Username - user name for authentication - overrides one given on handler initialization)</li>
* <li>Password - password for authentication - overrides one given on handler initialization)</li>
* <li>AuthUrl - url that is handling authentication (usually j_security_check url)</li>
* <li>HandleResponseErrors - optional parameter that instructs handler to throw errors in case
* of non successful response codes (other than 2XX)</li>
* <li>ResultClass - fully qualified class name of the class that response should be transformed to,
* if not given string format will be returned</li>
* </ul>
*/
public class RESTWorkItemHandler extends AbstractLogOrThrowWorkItemHandler {
private static final Logger logger = LoggerFactory.getLogger(RESTWorkItemHandler.class);
private String username;
private String password;
private AuthenticationType type;
private String authUrl;
private ClassLoader classLoader;
// protected for test purpose
protected static boolean HTTP_CLIENT_API_43 = true;
static {
try {
Class.forName("org.apache.http.client.methods.RequestBuilder");
HTTP_CLIENT_API_43 = true;
} catch (ClassNotFoundException e) {
HTTP_CLIENT_API_43 = false;
}
}
/**
* Used when no authentication is required
*/
public RESTWorkItemHandler() {
logger.debug("REST work item handler will use http client 4.3 api " + HTTP_CLIENT_API_43);
this.type = AuthenticationType.NONE;
this.classLoader = this.getClass().getClassLoader();
}
/**
* Dedicated constructor when BASIC authentication method shall be used
* @param username - user name to be used for authentication
* @param password - password to be used for authentication
*/
public RESTWorkItemHandler(String username, String password) {
this();
this.username = username;
this.password = password;
this.type = AuthenticationType.BASIC;
this.classLoader = this.getClass().getClassLoader();
}
/**
* Dedicated constructor when FORM BASED authentication method shall be used
* @param username - user name to be used for authentication
* @param password - password to be used for authentication
* @param authUrl
*/
public RESTWorkItemHandler(String username, String password, String authUrl) {
this();
this.username = username;
this.password = password;
this.type = AuthenticationType.FORM_BASED;
this.authUrl = authUrl;
this.classLoader = this.getClass().getClassLoader();
}
/**
* Used when no authentication is required
*/
public RESTWorkItemHandler(ClassLoader classLoader) {
logger.debug("REST work item handler will use http client 4.3 api " + HTTP_CLIENT_API_43);
this.type = AuthenticationType.NONE;
this.classLoader = classLoader;
}
/**
* Dedicated constructor when BASIC authentication method shall be used
* @param username - user name to be used for authentication
* @param password - password to be used for authentication
*/
public RESTWorkItemHandler(String username, String password, ClassLoader classLoader) {
this();
this.username = username;
this.password = password;
this.type = AuthenticationType.BASIC;
this.classLoader = classLoader;
}
/**
* Dedicated constructor when FORM BASED authentication method shall be used
* @param username - user name to be used for authentication
* @param password - password to be used for authentication
* @param authUrl
*/
public RESTWorkItemHandler(String username, String password, String authUrl, ClassLoader classLoader) {
this();
this.username = username;
this.password = password;
this.type = AuthenticationType.FORM_BASED;
this.authUrl = authUrl;
this.classLoader = classLoader;
}
public String getAuthUrl() {
return authUrl;
}
public void executeWorkItem(WorkItem workItem, WorkItemManager manager) {
boolean handleException = false;
// extract required parameters
String urlStr = (String) workItem.getParameter("Url");
String method = (String) workItem.getParameter("Method");
String handleExceptionStr = (String) workItem.getParameter("HandleResponseErrors");
String resultClass = (String) workItem.getParameter("ResultClass");
if (urlStr == null) {
throw new IllegalArgumentException("Url is a required parameter");
}
if (method == null || method.trim().length() == 0) {
method = "GET";
}
if (handleExceptionStr != null) {
handleException = Boolean.parseBoolean(handleExceptionStr);
}
Map<String,Object> params = workItem.getParameters();
// authentication type from parameters
AuthenticationType authType = type;
if (params.get("AuthType") != null) {
authType = AuthenticationType.valueOf((String) params.get("AuthType"));
}
// optional timeout config parameters, defaulted to 60 seconds
Integer connectTimeout = getParamAsInt(params.get("ConnectTimeout"));
if (connectTimeout==null) connectTimeout = 60000;
Integer readTimeout = getParamAsInt(params.get("ReadTimeout"));
if (readTimeout==null) readTimeout = 60000;
HttpClient httpClient = getHttpClient(readTimeout, connectTimeout);
Object methodObject = configureRequest(method, urlStr, params);
try {
HttpResponse response = doRequestWithAuthorization(httpClient, methodObject, params, authType);
StatusLine statusLine = response.getStatusLine();
int responseCode = statusLine.getStatusCode();
Map<String, Object> results = new HashMap<String, Object>();
HttpEntity respEntity = response.getEntity();
String responseBody = null;
String contentType = null;
if( respEntity != null ) {
responseBody = EntityUtils.toString(respEntity);
if (respEntity.getContentType() != null) {
contentType = respEntity.getContentType().getValue();
}
}
if (responseCode >= 200 && responseCode < 300) {
postProcessResult(responseBody, resultClass, contentType, results);
results.put("StatusMsg", "request to endpoint " + urlStr + " successfully completed " + statusLine.getReasonPhrase());
} else {
if (handleException) {
handleException(new RESTServiceException(responseCode, responseBody, urlStr));
} else {
logger.warn("Unsuccessful response from REST server (status: {}, endpoint: {}, response: {}",
responseCode, urlStr, responseBody);
results.put("StatusMsg", "endpoint " + urlStr + " could not be reached: " + responseBody);
}
}
results.put("Status", responseCode);
// notify manager that work item has been completed
manager.completeWorkItem(workItem.getId(), results);
} catch (Exception e) {
handleException(e);
} finally {
try {
close(httpClient, methodObject);
} catch( Exception e ) {
// no idea if this throws something, but we still don't care!
}
}
}
protected Integer getParamAsInt(Object param) {
if (param == null) {
return null;
}
if (param instanceof String && !((String) param).isEmpty()) {
return Integer.parseInt((String) param);
} if (param instanceof Number) {
return ((Number) param).intValue();
}
return null;
}
protected void setBody(RequestBuilder builder, Map<String, Object> params) {
if (params.containsKey("Content")) {
try {
String contentType = (String)params.get("ContentType");
Object content = params.get("Content");
if (!(content instanceof String)) {
content = transformRequest(content, contentType);
}
StringEntity entity = new StringEntity((String)content, ContentType.parse(contentType));
builder.setEntity(entity);
} catch (UnsupportedCharsetException e) {
throw new RuntimeException("Cannot set body for REST request [" + builder.getMethod() + "] " + builder.getUri(), e);
}
}
}
protected void setBody(HttpRequestBase theMethod, Map<String, Object> params) {
if (params.containsKey("Content")) {
Object content = params.get("Content");
if (!(content instanceof String)) {
content = transformRequest(content, (String)params.get("ContentType"));
}
((HttpEntityEnclosingRequestBase)theMethod).setEntity(new StringEntity((String) content,
ContentType.parse((String)params.get("ContentType"))));
}
}
protected void postProcessResult(String result, String resultClass, String contentType, Map<String, Object> results) {
if (!StringUtils.isEmpty(resultClass) && !StringUtils.isEmpty(contentType)) {
try {
Class<?> clazz = Class.forName(resultClass, true, classLoader);
Object resultObject = transformResult(clazz, contentType, result);
results.put("Result", resultObject);
} catch (Throwable e) {
throw new RuntimeException("Unable to transform respose to object", e);
}
} else {
results.put("Result", result);
}
}
protected String transformRequest(Object data, String contentType) {
try {
if (contentType.toLowerCase().contains("application/json")) {
ObjectMapper mapper = new ObjectMapper();
return mapper.writeValueAsString(data);
} else if (contentType.toLowerCase().contains("application/xml")) {
StringWriter stringRep = new StringWriter();
JAXBContext jaxbContext = JAXBContext.newInstance(new Class[]{data.getClass()});
jaxbContext.createMarshaller().marshal(data, stringRep);
return stringRep.toString();
}
} catch (Exception e) {
throw new RuntimeException("Unable to transform request to object", e);
}
throw new IllegalArgumentException("Unable to find transformer for content type '" +contentType + "' to handle data " + data);
}
protected Object transformResult(Class<?> clazz, String contentType, String content) throws Exception {
if (contentType.toLowerCase().contains("application/json")) {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(content, clazz);
} else if (contentType.toLowerCase().contains("application/xml")) {
StringReader result = new StringReader(content);
JAXBContext jaxbContext = JAXBContext.newInstance(new Class[]{clazz});
return jaxbContext.createUnmarshaller().unmarshal(result);
}
logger.warn("Unable to find transformer for content type '{}' to handle for content '{}'", contentType, content);
// unknown content type, returning string representation
return content;
}
protected HttpResponse doRequestWithAuthorization(HttpClient httpclient, Object method, Map<String, Object> params, AuthenticationType authType) {
if (HTTP_CLIENT_API_43) {
return doRequestWithAuthorization(httpclient, (RequestBuilder) method, params, authType);
} else {
return doRequestWithAuthorization(httpclient, (HttpRequestBase) method, params, authType);
}
}
/**
* This method does the actual request, including the setup for authorization.
* </p>
* It is <b>not</b> responsible for cleaning up after the last request that it does.
* </p>
* It <i>is</i> responsible for cleaning up after all previous request, such as for form-based authentication, that happen.
*
* @param httpclient The {@link HttpClient} instance
* @param requestBuilder The {@link RequestBuilder} instance
* @param params The parameters that may be needed for authentication
* @return A {@link HttpResponse} instance from which we can extract the content
*/
protected HttpResponse doRequestWithAuthorization(HttpClient httpclient, RequestBuilder requestBuilder, Map<String, Object> params, AuthenticationType type) {
// no authorization
if (type == null || type == AuthenticationType.NONE) {
HttpUriRequest request = requestBuilder.build();
try {
return httpclient.execute(request);
} catch( Exception e ) {
throw new RuntimeException("Could not execute request [" + request.getMethod() + "] " + request.getURI(), e);
}
}
// user/password
String u = (String) params.get("Username");
String p = (String) params.get("Password");
if (u == null || p == null) {
u = this.username;
p = this.password;
}
if (u == null) {
throw new IllegalArgumentException("Could not find username");
}
if (p == null) {
throw new IllegalArgumentException("Could not find password");
}
if (type == AuthenticationType.BASIC) {
// basic auth
URI requestUri = requestBuilder.getUri();
HttpHost targetHost = new HttpHost(requestUri.getHost(), requestUri.getPort(), requestUri.getScheme());
// Create AuthCache instance and add it: so that HttpClient thinks that it has already queried (as per the HTTP spec)
// - generate BASIC scheme object and add it to the local auth cache
AuthCache authCache = new BasicAuthCache();
BasicScheme basicAuth = new BasicScheme();
authCache.put(targetHost, basicAuth);
// - add AuthCache to the execution context:
HttpClientContext clientContext = HttpClientContext.create();
CredentialsProvider credsProvider = new BasicCredentialsProvider();
credsProvider.setCredentials(
// specify host and port, since that is safer/more secure
new AuthScope(requestUri.getHost(), requestUri.getPort(), AuthScope.ANY_REALM),
new UsernamePasswordCredentials(u, p)
);
clientContext.setCredentialsProvider(credsProvider);
clientContext.setAuthCache(authCache);
// - execute request
HttpUriRequest request = requestBuilder.build();
try {
return httpclient.execute(targetHost, request, clientContext);
} catch( Exception e ) {
throw new RuntimeException("Could not execute request with preemptive authentication [" + request.getMethod() + "] " + request.getURI(), e);
}
} else if (type == AuthenticationType.FORM_BASED) {
// form auth
// 1. do initial request to trigger authentication
HttpUriRequest request = requestBuilder.build();
int statusCode = -1;
try {
HttpResponse initialResponse = httpclient.execute(request);
statusCode = initialResponse.getStatusLine().getStatusCode();
} catch (IOException e) {
throw new RuntimeException("Could not execute request for form-based authentication", e);
} finally {
// weird, but this is the method that releases resources, including the connection
request.abort();
}
// 1b. form authentication requests should have a status of 401
// See: www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2
if( statusCode != HttpStatus.SC_UNAUTHORIZED ) {
logger.error("Expected form authentication request with status {} but status on response is {}: proceeding anyways",
HttpStatus.SC_UNAUTHORIZED, statusCode);
}
// 2. do POST form request to authentiate
String authUrlStr = (String) params.get("AuthUrl");
if (authUrlStr == null) {
authUrlStr = authUrl;
}
if (authUrlStr == null) {
throw new IllegalArgumentException("Could not find authentication url");
}
HttpPost authMethod = new HttpPost(authUrlStr);
List<NameValuePair> formParams = new ArrayList<NameValuePair>(2);
formParams.add(new BasicNameValuePair("j_username", u));
formParams.add(new BasicNameValuePair("j_password", p));
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(formParams);
} catch( UnsupportedEncodingException uee ) {
throw new RuntimeException("Could not encode authentication parameters into request body", uee);
}
authMethod.setEntity(formEntity);
try {
httpclient.execute(authMethod);
} catch (IOException e) {
throw new RuntimeException("Could not initialize form-based authentication", e);
} finally {
authMethod.releaseConnection();
}
// 3. rebuild request and execute
request = requestBuilder.build();
try {
return httpclient.execute(request);
} catch( Exception e ) {
throw new RuntimeException("Could not execute request [" + request.getMethod() + "] " + request.getURI(), e);
}
} else {
throw new RuntimeException("Unknown AuthenticationType " + type);
}
}
protected HttpResponse doRequestWithAuthorization(HttpClient httpclient, HttpRequestBase httpMethod, Map<String, Object> params, AuthenticationType type) {
if (type == null || type == AuthenticationType.NONE) {
try {
return httpclient.execute(httpMethod);
} catch( Exception e ) {
throw new RuntimeException("Could not execute request [" + httpMethod.getMethod() + "] " + httpMethod.getURI(), e);
}
}
String u = (String) params.get("Username");
String p = (String) params.get("Password");
if (u == null || p == null) {
u = this.username;
p = this.password;
}
if (u == null) {
throw new IllegalArgumentException("Could not find username");
}
if (p == null) {
throw new IllegalArgumentException("Could not find password");
}
if (type == AuthenticationType.BASIC) {
HttpHost targetHost = new HttpHost(httpMethod.getURI().getHost(), httpMethod.getURI().getPort(), httpMethod.getURI().getScheme());
((DefaultHttpClient)httpclient).getCredentialsProvider().setCredentials(
new AuthScope(targetHost.getHostName(), targetHost.getPort()),
new UsernamePasswordCredentials(u, p));
// Create AuthCache instance
AuthCache authCache = new BasicAuthCache();
// Generate BASIC scheme object and add it to the local
// auth cache
BasicScheme basicAuth = new BasicScheme();
authCache.put(targetHost, basicAuth);
// Add AuthCache to the execution context
BasicHttpContext localcontext = new BasicHttpContext();
localcontext.setAttribute(ClientContext.AUTH_CACHE, authCache);
try {
return httpclient.execute(targetHost, httpMethod, localcontext);
} catch( Exception e ) {
throw new RuntimeException("Could not execute request [" + httpMethod.getMethod() + "] " + httpMethod.getURI(), e);
}
} else if (type == AuthenticationType.FORM_BASED) {
String authUrlStr = (String) params.get("AuthUrl");
if (authUrlStr == null) {
authUrlStr = authUrl;
}
if (authUrlStr == null) {
throw new IllegalArgumentException("Could not find authentication url");
}
try {
httpclient.execute(httpMethod);
} catch (IOException e) {
throw new RuntimeException("Could not execute request for form-based authentication", e);
} finally {
httpMethod.releaseConnection();
}
HttpPost authMethod = new HttpPost(authUrlStr);
List <NameValuePair> nvps = new ArrayList <NameValuePair>();
nvps.add(new BasicNameValuePair("j_username", u));
nvps.add(new BasicNameValuePair("j_password", p));
authMethod.setEntity(new UrlEncodedFormEntity(nvps, Consts.UTF_8));
try {
httpclient.execute(authMethod);
} catch (IOException e) {
throw new RuntimeException("Could not initialize form-based authentication", e);
} finally {
authMethod.releaseConnection();
}
try {
return httpclient.execute(httpMethod);
} catch( Exception e ) {
throw new RuntimeException("Could not execute request [" + httpMethod.getMethod() + "] " + httpMethod.getURI(), e);
}
} else {
throw new RuntimeException("Unknown AuthenticationType " + type);
}
}
public void abortWorkItem(WorkItem workItem, WorkItemManager manager) {
// Do nothing, this work item cannot be aborted
}
public enum AuthenticationType {
NONE,
BASIC,
FORM_BASED
}
protected HttpClient getHttpClient(Integer readTimeout, Integer connectTimeout) {
if (HTTP_CLIENT_API_43) {
RequestConfig config = RequestConfig.custom()
.setSocketTimeout(readTimeout)
.setConnectTimeout(connectTimeout)
.setConnectionRequestTimeout(connectTimeout)
.build();
HttpClientBuilder clientBuilder = HttpClientBuilder.create()
.setDefaultRequestConfig(config);
HttpClient httpClient = clientBuilder.build();
return httpClient;
} else {
DefaultHttpClient httpClient = new DefaultHttpClient();
httpClient.getParams().setIntParameter(CoreConnectionPNames.SO_TIMEOUT, readTimeout);
httpClient.getParams().setIntParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, connectTimeout);
return httpClient;
}
}
protected void close(HttpClient httpClient, Object httpMethod) throws IOException {
if (HTTP_CLIENT_API_43) {
((CloseableHttpClient) httpClient).close();
} else {
((HttpRequestBase)httpMethod).releaseConnection();
}
}
protected Object configureRequest(String method, String urlStr, Map<String, Object> params) {
if (HTTP_CLIENT_API_43) {
RequestBuilder builder = null;
if ("GET".equals(method)) {
builder = RequestBuilder.get().setUri(urlStr);
} else if ("POST".equals(method)) {
builder = RequestBuilder.post().setUri(urlStr);
setBody(builder, params);
} else if ("PUT".equals(method)) {
builder = RequestBuilder.put().setUri(urlStr);
setBody(builder, params);
} else if ("DELETE".equals(method)) {
builder = RequestBuilder.delete().setUri(urlStr);
} else {
throw new IllegalArgumentException("Method " + method + " is not supported");
}
return builder;
} else {
HttpRequestBase theMethod = null;
if ("GET".equals(method)) {
theMethod = new HttpGet(urlStr);
} else if ("POST".equals(method)) {
theMethod = new HttpPost(urlStr);
setBody(theMethod, params);
} else if ("PUT".equals(method)) {
theMethod = new HttpPut(urlStr);
setBody(theMethod, params);
} else if ("DELETE".equals(method)) {
theMethod = new HttpDelete(urlStr);
} else {
throw new IllegalArgumentException("Method " + method + " is not supported");
}
return theMethod;
}
}
}