/*
* Copyright (C) 2015 Strand Life Sciences.
*
* 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 com.strandls.alchemy.rest.client;
import java.io.File;
import java.io.InputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Map.Entry;
import javassist.Modifier;
import javassist.util.proxy.MethodFilter;
import javassist.util.proxy.MethodHandler;
import javassist.util.proxy.ProxyFactory;
import javassist.util.proxy.ProxyObject;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Provider;
import javax.inject.Singleton;
import javax.ws.rs.CookieParam;
import javax.ws.rs.FormParam;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.HttpMethod;
import javax.ws.rs.InternalServerErrorException;
import javax.ws.rs.MatrixParam;
import javax.ws.rs.PathParam;
import javax.ws.rs.QueryParam;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.Entity;
import javax.ws.rs.client.Invocation.Builder;
import javax.ws.rs.client.WebTarget;
import javax.ws.rs.core.Cookie;
import javax.ws.rs.core.Form;
import javax.ws.rs.core.GenericType;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.glassfish.jersey.media.multipart.ContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataMultiPart;
import org.glassfish.jersey.media.multipart.FormDataParam;
import org.glassfish.jersey.media.multipart.MultiPart;
import org.glassfish.jersey.media.multipart.file.FileDataBodyPart;
import org.glassfish.jersey.media.multipart.file.StreamDataBodyPart;
import org.objenesis.ObjenesisStd;
import com.strandls.alchemy.rest.client.exception.ResponseToThrowableMapper;
import com.strandls.alchemy.rest.client.request.RequestBuilderFilter;
/**
* Factory for jersey based proxy clients.
*
* @author Ashish Shinde
*
*/
@Singleton
@Slf4j
public class AlchemyRestClientFactory {
/**
* Handles rest method invocation for a single rest service.
*
* @author Ashish Shinde
*
*/
@RequiredArgsConstructor
private static class RestMethodInvocationHandler implements MethodHandler {
/**
* The base URI.
*/
private final String baseUri;
/**
* Jax rs client provider.
*/
private final Provider<Client> clientProvider;
/**
* Rest interface metadata.
*/
private final RestInterfaceMetadata restInterfaceMetadata;
/**
* Maps server side errors to local errors.
*/
private final ResponseToThrowableMapper responseToThrowableMapper;
/**
* The request builder filter.
*/
private final RequestBuilderFilter builderFilter;
/**
* Create the path for the rest method.
*
* @param methodMetaData
* the method meta data.
* @param arguments
* the method arguments, used to add matrix and path
* parameters to the generated path.
* @return the absolute remote rest path for this method invocation.
*/
private String getPath(final RestMethodMetadata methodMetaData, final Object[] arguments) {
final UriBuilder uriBuilder = UriBuilder.fromPath(baseUri);
if (!StringUtils.isBlank(restInterfaceMetadata.getPath())) {
uriBuilder.path(restInterfaceMetadata.getPath());
}
if (!StringUtils.isBlank(methodMetaData.getPath())) {
uriBuilder.path(methodMetaData.getPath());
}
// add matrix parameters to the path
final Annotation[][] parameterAnnotations = methodMetaData.getParameterAnnotations();
final Map<String, Object> pathParamsMap = new LinkedHashMap<String, Object>();
for (int i = 0; i < parameterAnnotations.length && i < arguments.length; i++) {
final Annotation[] annotations = parameterAnnotations[i];
final Object argument = arguments[i];
for (final Annotation annotation : annotations) {
if (annotation instanceof MatrixParam) {
final String name = ((MatrixParam) annotation).value();
Object[] values = new Object[] {};
if (argument != null && argument.getClass().isArray()) {
values = (Object[]) argument;
} else if (argument instanceof Collection) {
@SuppressWarnings("unchecked")
final Collection<Object> collection = (Collection<Object>) argument;
values = collection.toArray();
} else {
values = new Object[] { argument };
}
uriBuilder.matrixParam(name, values);
} else if (annotation instanceof PathParam) {
pathParamsMap.put(((PathParam) annotation).value(), argument);
}
}
}
// add path params to the path
return uriBuilder.buildFromMap(pathParamsMap).toString();
}
/*
* (non-Javadoc)
* @see javassist.util.proxy.MethodHandler#invoke(java.lang.Object,
* java.lang.reflect.Method, java.lang.reflect.Method,
* java.lang.Object[])
*/
@Override
public Object invoke(final Object self, final Method thisMethod, final Method proceed,
final Object[] arguments) throws Throwable {
final RestMethodMetadata methodMetaData =
restInterfaceMetadata.getMethodMetaData().get(thisMethod);
if (methodMetaData == null) {
throw new NotRestMethodException(thisMethod);
}
final String path = getPath(methodMetaData, arguments);
log.debug("Invoking rest service at {}", path);
final Client client = clientProvider.get();
WebTarget webTarget = client.target(path);
Entity<?> entity = null;
final Annotation[][] parameterAnnotations = methodMetaData.getParameterAnnotations();
final String bodyParameterMediaType =
methodMetaData.getConsumed().isEmpty() ? null : methodMetaData.getConsumed()
.get(0);
// set query params
for (int i = 0; i < arguments.length; i++) {
final Object argument = arguments[i];
if (parameterAnnotations.length <= i) {
// body parameter without annotation
entity = toEntity(argument, bodyParameterMediaType);
continue;
}
final Annotation[] annotations = parameterAnnotations[i];
if (annotations == null || annotations.length == 0) {
// body parameter without annotation
entity = toEntity(argument, bodyParameterMediaType);
continue;
}
for (final Annotation annotation : annotations) {
if (annotation instanceof QueryParam) {
final String key = ((QueryParam) annotation).value();
final String value = ObjectUtils.toString(argument);
webTarget = webTarget.queryParam(key, value);
}
}
}
// create the request builder
Builder webRequestBuilder = webTarget.request();
webRequestBuilder.accept(methodMetaData.getProduced().toArray(new String[0]));
webRequestBuilder =
webRequestBuilder.accept(methodMetaData.getConsumed().toArray(new String[0]));
if (builderFilter != null) {
builderFilter.apply(webRequestBuilder);
}
// for form params
Form formParams = null;
// process cookie and header params
for (int i = 0; i < arguments.length; i++) {
if (i >= parameterAnnotations.length) {
continue;
}
final Annotation[] annotations = parameterAnnotations[i];
final Object argument = arguments[i];
for (final Annotation annotation : annotations) {
if (annotation instanceof CookieParam) {
// add cookie to the request
Cookie cookie = null;
if (argument instanceof Cookie) {
cookie = (Cookie) argument;
} else {
cookie =
new Cookie(((CookieParam) annotation).value(),
ObjectUtils.toString(argument));
}
webRequestBuilder = webRequestBuilder.cookie(cookie);
} else if (annotation instanceof HeaderParam) {
// add header param
final String key = ((HeaderParam) annotation).value();
final String value = ObjectUtils.toString(argument);
webRequestBuilder = webRequestBuilder.header(key, value);
} else if (annotation instanceof FormParam) {
if (formParams == null) {
formParams = new javax.ws.rs.core.Form();
}
formParams.param(((FormParam) annotation).value(),
ObjectUtils.toString(argument));
}
}
}
if (formParams != null) {
// cannot have form parameters and body parameters without
// annotation
assert entity == null;
// for form parameters the method should be post
assert HttpMethod.POST.equals(methodMetaData.getHttpMethod());
entity = toEntity(formParams, MediaType.APPLICATION_FORM_URLENCODED);
}
final FormDataMultiPart formDataMultiPart =
processFormDataParams(arguments, parameterAnnotations);
if (formDataMultiPart != null) {
// Cannot have form parameters and body parameters without
// annotation
assert entity == null;
// for form parameters the method should be post
assert HttpMethod.POST.equals(methodMetaData.getHttpMethod());
entity = toEntity(formDataMultiPart, formDataMultiPart.getMediaType().toString());
}
// Get the return type.
@SuppressWarnings("rawtypes")
final GenericType<?> returnType = new GenericType(thisMethod.getGenericReturnType()) {
};
try {
Object retval = null;
final String httpMethod = methodMetaData.getHttpMethod();
if (HttpMethod.GET.equals(httpMethod)) {
retval = webRequestBuilder.get(returnType);
} else if (HttpMethod.PUT.equals(httpMethod)) {
retval = webRequestBuilder.put(entity, returnType);
} else if (HttpMethod.POST.equals(httpMethod)) {
retval = webRequestBuilder.post(entity, returnType);
} else if (HttpMethod.DELETE.equals(httpMethod)) {
retval = webRequestBuilder.delete(returnType);
}
return retval;
} catch (final InternalServerErrorException e) {
throw responseToThrowableMapper.apply(e.getResponse());
}
}
/**
* Process form data params.
*
* @param arguments
* the function call arguments.
* @param parameterAnnotations
* function parameter annotations.
* @return form data multipart object if the funtion contains form data
* elements.
*/
private FormDataMultiPart processFormDataParams(final Object[] arguments,
final Annotation[][] parameterAnnotations) {
FormDataMultiPart formDataMultiPart = null;
// map from param name to content disposition
final Map<String, ContentDisposition> contentDispositions =
new HashMap<String, ContentDisposition>();
// map from param name to input streams
final Map<String, InputStream> inputstreams = new HashMap<String, InputStream>();
for (int i = 0; i < arguments.length; i++) {
if (i >= parameterAnnotations.length) {
continue;
}
final Annotation[] annotations = parameterAnnotations[i];
for (final Annotation annotation : annotations) {
if (annotation instanceof FormDataParam) {
if (formDataMultiPart == null) {
formDataMultiPart = new FormDataMultiPart();
}
final Object argument = arguments[i];
final String paramName = ((FormDataParam) annotation).value();
if (argument instanceof File) {
formDataMultiPart.bodyPart(new FileDataBodyPart(paramName,
(File) argument));
} else if (argument instanceof InputStream) {
inputstreams.put(paramName, (InputStream) argument);
} else if (argument instanceof FormDataContentDisposition) {
contentDispositions.put(paramName, (ContentDisposition) argument);
} else {
formDataMultiPart.field(paramName, ObjectUtils.toString(argument));
}
}
}
}
if (formDataMultiPart != null && !inputstreams.isEmpty()) {
// we have input streams that may have content dispositions
for (final Entry<String, InputStream> streamEntry : inputstreams.entrySet()) {
final String paramName = streamEntry.getKey();
if (contentDispositions.containsKey(paramName)) {
// we have a content disposition for this input stream
final ContentDisposition contentDisposition =
contentDispositions.get(paramName);
final StreamDataBodyPart streamBodyPart =
new StreamDataBodyPart(paramName, streamEntry.getValue(),
contentDisposition.getFileName());
formDataMultiPart.bodyPart(streamBodyPart);
} else {
final StreamDataBodyPart streamBodyPart =
new StreamDataBodyPart(paramName, streamEntry.getValue(), paramName);
formDataMultiPart.bodyPart(streamBodyPart);
}
}
}
return formDataMultiPart;
}
/**
* Converts an object to an entity.
*
* @param object
* the object.
* @param bodyParameterMediaType
* the media type. If <code>null</code>
* {@link MediaType#MEDIA_TYPE_WILDCARD} will be used.
* @return converted entity.
*/
private Entity<Object> toEntity(final Object object, String bodyParameterMediaType) {
if (object instanceof MultiPart) {
bodyParameterMediaType = ((MultiPart) object).getMediaType().toString();
}
return !StringUtils.isBlank(bodyParameterMediaType) ? Entity.entity(object,
bodyParameterMediaType) : Entity.entity(object, MediaType.MEDIA_TYPE_WILDCARD);
}
}
/**
* The base uri named param name.
*/
public static final String BASE_URI_NAMED_PARAM =
"com.strandls.alchemy.rest.client.AlchemyRestClientFactory.baseURI";
/**
* The base URI.
*/
@NonNull
private final String baseUri;
/**
* Jax rs client provider.
*/
@NonNull
private final Provider<Client> clientProvider;
/**
* The rest interface analyzer.
*/
private final RestInterfaceAnalyzer interfaceAnalyzer;
/**
* Object instantiator.
*/
private final ObjenesisStd objenesis;
/**
* Maps {@link Response} to a {@link Throwable} object for server side
* exceptions.
*/
private final ResponseToThrowableMapper responseToThrowableMapper;
/**
* The request biulder filter.
*/
private final RequestBuilderFilter builderFilter;
/**
* Creates the new factory.
*
* @param baseUri
* the base URI for the rest service.
* @param clientProvider
* the {@link Client} provider.
* @param interfaceAnalyzer
* the interface analyzer.
*/
@Inject
public AlchemyRestClientFactory(@Named(BASE_URI_NAMED_PARAM) final String baseUri,
final Provider<Client> clientProvider, final RestInterfaceAnalyzer interfaceAnalyzer,
final ResponseToThrowableMapper responseToThrowableMapper,
final RequestBuilderFilter builderFilter) {
this.baseUri = baseUri;
this.clientProvider = clientProvider;
this.interfaceAnalyzer = interfaceAnalyzer;
this.objenesis = new ObjenesisStd();
this.responseToThrowableMapper = responseToThrowableMapper;
this.builderFilter = builderFilter;
}
/**
* Get an instance of a rest proxy instance for the service class.
*
* @param serviceClass
* the service class.
* @return the proxy implementation that invokes the remote service.
* @throws Exception
*/
@SuppressWarnings("unchecked")
public <T> T getInstance(@NonNull final Class<T> serviceClass) throws Exception {
final ProxyFactory factory = new ProxyFactory();
if (serviceClass.isInterface()) {
factory.setInterfaces(new Class[] { serviceClass });
} else {
factory.setSuperclass(serviceClass);
}
factory.setFilter(new MethodFilter() {
@Override
public boolean isHandled(final Method method) {
return Modifier.isPublic(method.getModifiers());
}
});
final Class<?> klass = factory.createClass();
final Object instance = objenesis.getInstantiatorOf(klass).newInstance();
((ProxyObject) instance).setHandler(new RestMethodInvocationHandler(baseUri,
clientProvider, interfaceAnalyzer.analyze(serviceClass), responseToThrowableMapper,
builderFilter));
return (T) instance;
}
}