/*
* Copyright (c) 2014. Escalon System-Entwicklung, Dietrich Schulten
*
* 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 de.escalon.hypermedia.spring;
import de.escalon.hypermedia.PropertyUtils;
import de.escalon.hypermedia.action.Action;
import de.escalon.hypermedia.action.Cardinality;
import de.escalon.hypermedia.action.Input;
import de.escalon.hypermedia.action.ResourceHandler;
import de.escalon.hypermedia.affordance.ActionDescriptor;
import de.escalon.hypermedia.affordance.ActionInputParameter;
import de.escalon.hypermedia.affordance.DataType;
import de.escalon.hypermedia.affordance.PartialUriTemplate;
import org.jetbrains.annotations.NotNull;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.hateoas.MethodLinkBuilderFactory;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.core.AnnotationMappingDiscoverer;
import org.springframework.hateoas.core.DummyInvocationUtils;
import org.springframework.hateoas.core.MappingDiscoverer;
import org.springframework.hateoas.core.MethodParameters;
import org.springframework.http.HttpEntity;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.beans.PropertyDescriptor;
import java.lang.annotation.Annotation;
import java.lang.reflect.*;
import java.util.*;
/**
* Factory for {@link AffordanceBuilder}s in a Spring MVC rest service. Normally one should use the static methods of
* AffordanceBuilder to get an AffordanceBuilder. Created by dschulten on 03.10.2014.
*/
public class AffordanceBuilderFactory implements MethodLinkBuilderFactory<AffordanceBuilder> {
private static final MappingDiscoverer MAPPING_DISCOVERER = new AnnotationMappingDiscoverer(RequestMapping.class);
@Override
public AffordanceBuilder linkTo(Method method, Object... parameters) {
return linkTo(method.getDeclaringClass(), method, parameters);
}
@Override
public AffordanceBuilder linkTo(Class<?> controller, Method method, Object... parameters) {
String pathMapping = MAPPING_DISCOVERER.getMapping(controller, method);
Set<String> requestParamNames = getRequestParamNames(method);
Set<String> inputBeanParamNames = getInputBeanParamNames(method);
String query = join(requestParamNames, inputBeanParamNames);
String mapping = StringUtils.isEmpty(query) ? pathMapping : pathMapping + "{?" + query + "}";
PartialUriTemplate partialUriTemplate = new PartialUriTemplate(AffordanceBuilder.getBuilder()
.build()
.toString() + mapping);
Map<String, Object> values = new HashMap<String, Object>();
Iterator<String> variableNames = partialUriTemplate.getVariableNames()
.iterator();
// there may be more or less mapping variables than arguments
for (Object parameter : parameters) {
if (!variableNames.hasNext()) {
break;
}
values.put(variableNames.next(), parameter);
}
// there may be more or less mapping variables than arguments
// do not use input bean param names here
for (Object argument : parameters) {
if (!variableNames.hasNext()) {
break;
}
String variableName = variableNames.next();
if (!inputBeanParamNames.contains(variableName)) {
values.put(variableName, argument);
}
}
ActionDescriptor actionDescriptor = createActionDescriptor(method, values, parameters);
return new AffordanceBuilder(partialUriTemplate.expand(values), Collections.singletonList(actionDescriptor));
}
private String join(Set<String>... params) {
StringBuilder sb = new StringBuilder();
for (Set<String> paramSet : params) {
for (String param : paramSet) {
if (sb.length() > 0) {
sb.append(',');
}
sb.append(param);
}
}
return sb.toString();
}
@Override
public AffordanceBuilder linkTo(Class<?> target) {
return linkTo(target, new Object[0]);
}
@Override
public AffordanceBuilder linkTo(Class<?> controller, Object... parameters) {
Assert.notNull(controller);
String mapping = MAPPING_DISCOVERER.getMapping(controller);
PartialUriTemplate partialUriTemplate = new PartialUriTemplate(mapping == null ? "/" : mapping);
Map<String, Object> values = new HashMap<String, Object>();
Iterator<String> names = partialUriTemplate.getVariableNames()
.iterator();
// there may be more or less mapping variables than arguments
for (Object parameter : parameters) {
if (!names.hasNext()) {
break;
}
values.put(names.next(), parameter);
}
return new AffordanceBuilder().slash(partialUriTemplate.expand(values));
}
// not in Spring 3.x
// @Override
// public AffordanceBuilder linkTo(Class<?> controller, Map<String, ?> parameters) {
// String mapping = MAPPING_DISCOVERER.getMapping(controller);
// PartialUriTemplate partialUriTemplate = new PartialUriTemplate(mapping == null ? "/" : mapping);
// return new AffordanceBuilder().slash(partialUriTemplate.expand(parameters));
// }
@Override
public AffordanceBuilder linkTo(Object invocationValue) {
Assert.isInstanceOf(DummyInvocationUtils.LastInvocationAware.class, invocationValue);
DummyInvocationUtils.LastInvocationAware invocations = (DummyInvocationUtils.LastInvocationAware)
invocationValue;
DummyInvocationUtils.MethodInvocation invocation = invocations.getLastInvocation();
Method invokedMethod = invocation.getMethod();
String pathMapping = MAPPING_DISCOVERER.getMapping(invokedMethod);
Iterator<Object> classMappingParameters = invocations.getObjectParameters();
Set<String> requestParamNames = getRequestParamNames(invokedMethod);
Set<String> inputBeanParamNames = getInputBeanParamNames(invokedMethod);
String query = join(requestParamNames, inputBeanParamNames);
String mapping = StringUtils.isEmpty(query) ? pathMapping : pathMapping + "{?" + query + "}";
PartialUriTemplate partialUriTemplate = new PartialUriTemplate(AffordanceBuilder.getBuilder()
.build()
.toString() + mapping);
Map<String, Object> values = new HashMap<String, Object>();
Iterator<String> variableNames = partialUriTemplate.getVariableNames()
.iterator();
while (classMappingParameters.hasNext()) {
values.put(variableNames.next(), classMappingParameters.next());
}
// there may be more or less mapping variables than arguments
// do not use input bean param names here
for (Object argument : invocation.getArguments()) {
if (!variableNames.hasNext()) {
break;
}
String variableName = variableNames.next();
if (!inputBeanParamNames.contains(variableName)) {
values.put(variableName, argument);
}
}
ActionDescriptor actionDescriptor = createActionDescriptor(
invocation.getMethod(), values, invocation.getArguments());
return new AffordanceBuilder(partialUriTemplate.expand(values), Collections.singletonList(actionDescriptor));
}
private Set<String> getInputBeanParamNames(Method invokedMethod) {
MethodParameters parameters = new MethodParameters(invokedMethod);
final List<MethodParameter> inputParams = parameters.getParametersWith(Input.class);
Set<String> ret = new LinkedHashSet<String>(inputParams.size());
for (MethodParameter inputParam : inputParams) {
Class<?> parameterType = inputParam.getParameterType();
// only use @Input param which is a bean or map and has no other annotations
// can't use Spring RequestParam etc. to avoid Spring MVC dependency
if (inputParam.getParameterAnnotations().length == 1 &&
!(DataType.isSingleValueType(parameterType) || DataType.isArrayOrCollection(parameterType))) {
Input inputAnnotation = inputParam.getParameterAnnotation(Input.class);
Set<String> explicitlyIncludedParams = new LinkedHashSet<String>(inputParams.size());
Collections.addAll(explicitlyIncludedParams, inputAnnotation.include());
Collections.addAll(explicitlyIncludedParams, inputAnnotation.hidden());
Collections.addAll(explicitlyIncludedParams, inputAnnotation.readOnly());
if (Map.class.isAssignableFrom(parameterType)) {
ret.addAll(explicitlyIncludedParams);
} else {
Set<String> inputBeanPropertyNames = getWritablePropertyNames(parameterType);
if (explicitlyIncludedParams.isEmpty()) {
ret.addAll(inputBeanPropertyNames);
} else {
for (String explicitlyIncludedParam : explicitlyIncludedParams) {
assertInputAnnotationConsistency(inputParam, inputBeanPropertyNames,
explicitlyIncludedParam, "includes");
ret.add(explicitlyIncludedParam);
}
}
String[] excludedParams = inputAnnotation.exclude();
for (String excludedParam : excludedParams) {
assertInputAnnotationConsistency(inputParam, inputBeanPropertyNames,
excludedParam, "excludes");
ret.remove(excludedParam);
}
}
break;
}
}
return ret;
}
@NotNull
private Set<String> getWritablePropertyNames(Class<?> parameterType) {
Set<String> inputBeanPropertyNames = new LinkedHashSet<String>();
Map<String, PropertyDescriptor> propertyDescriptors = PropertyUtils.getPropertyDescriptors
(parameterType);
for (PropertyDescriptor propertyDescriptor : propertyDescriptors.values()) {
if(propertyDescriptor.getWriteMethod() != null) {
inputBeanPropertyNames.add(propertyDescriptor.getName());
}
}
return inputBeanPropertyNames;
}
private void assertInputAnnotationConsistency(MethodParameter inputParam, Set<String> propertiesToCheckAgainst,
String propertyToCheck, String argumentKind) {
if (!propertiesToCheckAgainst.contains(propertyToCheck)) {
throw new IllegalStateException("@Include " +
"annotation on parameter '" + inputParam
.getParameterName() + "' of method '" + inputParam.getMethod()
.toGenericString() +
"' " + argumentKind + " property '" +
propertyToCheck + "' but there is no such property on " + inputParam
.getParameterType()
.getName());
}
}
private Set<String> getRequestParamNames(Method invokedMethod) {
MethodParameters parameters = new MethodParameters(invokedMethod);
final List<MethodParameter> requestParams = parameters.getParametersWith(RequestParam.class);
Set<String> params = new LinkedHashSet<String>(requestParams.size());
for (MethodParameter requestParam : requestParams) {
params.add(requestParam.getParameterName());
}
return params;
}
private ActionDescriptor createActionDescriptor(Method invokedMethod,
Map<String, Object> values, Object[] arguments) {
RequestMethod httpMethod = getHttpMethod(invokedMethod);
Type genericReturnType = invokedMethod.getGenericReturnType();
SpringActionDescriptor actionDescriptor =
new SpringActionDescriptor(invokedMethod.getName(), httpMethod.name());
actionDescriptor.setCardinality(getCardinality(invokedMethod, httpMethod, genericReturnType));
final Action actionAnnotation = AnnotationUtils.getAnnotation(invokedMethod, Action.class);
if (actionAnnotation != null) {
actionDescriptor.setSemanticActionType(actionAnnotation.value());
}
Map<String, ActionInputParameter> requestBodyMap = getActionInputParameters(RequestBody.class, invokedMethod,
arguments);
Assert.state(requestBodyMap.size() < 2, "found more than one request body on " + invokedMethod.getName());
for (ActionInputParameter value : requestBodyMap.values()) {
actionDescriptor.setRequestBody(value);
}
// the action descriptor needs to know the param type, value and name
Map<String, ActionInputParameter> requestParamMap =
getActionInputParameters(RequestParam.class, invokedMethod, arguments);
for (Map.Entry<String, ActionInputParameter> entry : requestParamMap.entrySet()) {
ActionInputParameter value = entry.getValue();
if (value != null) {
final String key = entry.getKey();
actionDescriptor.addRequestParam(key, value);
if (!value.isRequestBody()) {
values.put(key, value.getValueFormatted());
}
}
}
Map<String, ActionInputParameter> pathVariableMap =
getActionInputParameters(PathVariable.class, invokedMethod, arguments);
for (Map.Entry<String, ActionInputParameter> entry : pathVariableMap.entrySet()) {
ActionInputParameter actionInputParameter = entry.getValue();
if (actionInputParameter != null) {
final String key = entry.getKey();
actionDescriptor.addPathVariable(key, actionInputParameter);
if (!actionInputParameter.isRequestBody()) {
values.put(key, actionInputParameter.getValueFormatted());
}
}
}
Map<String, ActionInputParameter> requestHeadersMap =
getActionInputParameters(RequestHeader.class, invokedMethod, arguments);
for (Map.Entry<String, ActionInputParameter> entry : requestHeadersMap.entrySet()) {
ActionInputParameter actionInputParameter = entry.getValue();
if (actionInputParameter != null) {
final String key = entry.getKey();
actionDescriptor.addRequestHeader(key, actionInputParameter);
if (!actionInputParameter.isRequestBody()) {
values.put(key, actionInputParameter.getValueFormatted());
}
}
}
return actionDescriptor;
}
private Cardinality getCardinality(Method invokedMethod, RequestMethod httpMethod, Type genericReturnType) {
Cardinality cardinality;
ResourceHandler resourceAnn = AnnotationUtils.findAnnotation(invokedMethod, ResourceHandler.class);
if (resourceAnn != null) {
cardinality = resourceAnn.value();
} else {
if (RequestMethod.POST == httpMethod || containsCollection(genericReturnType)) {
cardinality = Cardinality.COLLECTION;
} else {
cardinality = Cardinality.SINGLE;
}
}
return cardinality;
}
private boolean containsCollection(Type genericReturnType) {
final boolean ret;
if (genericReturnType instanceof ParameterizedType) {
ParameterizedType t = (ParameterizedType) genericReturnType;
Type rawType = t.getRawType();
Assert.state(rawType instanceof Class<?>, "raw type is not a Class: " + rawType.toString());
Class<?> cls = (Class<?>) rawType;
if (HttpEntity.class.isAssignableFrom(cls)) {
Type[] typeArguments = t.getActualTypeArguments();
ret = containsCollection(typeArguments[0]);
} else if (Resources.class.isAssignableFrom(cls) ||
Collection.class.isAssignableFrom(cls)) {
ret = true;
} else {
ret = false;
}
} else if (genericReturnType instanceof GenericArrayType) {
ret = true;
} else if (genericReturnType instanceof WildcardType) {
WildcardType t = (WildcardType) genericReturnType;
ret = containsCollection(getBound(t.getLowerBounds())) || containsCollection(getBound(t.getUpperBounds()));
} else if (genericReturnType instanceof TypeVariable) {
ret = false;
} else if (genericReturnType instanceof Class) {
Class<?> cls = (Class<?>) genericReturnType;
ret = Resources.class.isAssignableFrom(cls) ||
Collection.class.isAssignableFrom(cls);
} else {
ret = false;
}
return ret;
}
private Type getBound(Type[] lowerBounds) {
Type ret;
if (lowerBounds != null && lowerBounds.length > 0) {
ret = lowerBounds[0];
} else {
ret = null;
}
return ret;
}
private static RequestMethod getHttpMethod(Method method) {
RequestMapping methodRequestMapping = AnnotationUtils.findAnnotation(method, RequestMapping.class);
RequestMethod requestMethod;
if (methodRequestMapping != null) {
RequestMethod[] methods = methodRequestMapping.method();
if (methods.length == 0) {
requestMethod = RequestMethod.GET;
} else {
requestMethod = methods[0];
}
} else {
requestMethod = RequestMethod.GET; // default
}
return requestMethod;
}
/**
* Returns {@link ActionInputParameter}s contained in the method link.
*
* @param annotation
* to inspect
* @param method
* must not be {@literal null}.
* @param arguments
* to the method link
* @return maps parameter names to parameter info
*/
private static Map<String, ActionInputParameter> getActionInputParameters(Class<? extends Annotation> annotation,
Method method, Object... arguments
) {
Assert.notNull(method, "MethodInvocation must not be null!");
MethodParameters parameters = new MethodParameters(method);
Map<String, ActionInputParameter> result = new HashMap<String, ActionInputParameter>();
for (MethodParameter parameter : parameters.getParametersWith(annotation)) {
final int parameterIndex = parameter.getParameterIndex();
final Object argument;
if (parameterIndex < arguments.length) {
argument = arguments[parameterIndex];
} else {
argument = null;
}
result.put(parameter.getParameterName(), new SpringActionInputParameter(parameter, argument));
}
return result;
}
}