/*
* 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.action.Input;
import de.escalon.hypermedia.action.Options;
import de.escalon.hypermedia.action.Select;
import de.escalon.hypermedia.action.Type;
import de.escalon.hypermedia.affordance.ActionDescriptor;
import de.escalon.hypermedia.affordance.ActionInputParameter;
import de.escalon.hypermedia.affordance.DataType;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.core.MethodParameter;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.format.support.DefaultFormattingConversionService;
import org.springframework.web.bind.annotation.*;
import java.lang.annotation.Annotation;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.*;
/**
* Describes a Spring MVC rest services method parameter value with recorded sample call value and input constraints.
*
* @author Dietrich Schulten
*/
public class SpringActionInputParameter implements ActionInputParameter {
private final TypeDescriptor typeDescriptor;
private final RequestBody requestBody;
private final RequestParam requestParam;
private final PathVariable pathVariable;
private final RequestHeader requestHeader;
private Input inputAnnotation;
private MethodParameter methodParameter;
private Object value;
private Boolean arrayOrCollection = null;
private Map<String, Object> inputConstraints = new HashMap<String, Object>();
private ConversionService conversionService = new DefaultFormattingConversionService();
/**
* Creates action input parameter.
*
* @param methodParameter
* to describe
* @param value
* used during sample invocation
* @param conversionService
* to apply to value
*/
public SpringActionInputParameter(MethodParameter methodParameter, Object value, ConversionService
conversionService) {
this.methodParameter = methodParameter;
this.value = value;
this.requestBody = methodParameter.getParameterAnnotation(RequestBody.class);
this.requestParam = methodParameter.getParameterAnnotation(RequestParam.class);
this.pathVariable = methodParameter.getParameterAnnotation(PathVariable.class);
this.requestHeader = methodParameter.getParameterAnnotation(RequestHeader.class);
// always determine input constraints,
// might be a nested property which is neither requestBody, requestParam nor pathVariable
this.inputAnnotation = methodParameter.getParameterAnnotation(Input.class);
if (inputAnnotation != null) {
putInputConstraint(Input.MIN, Integer.MIN_VALUE, inputAnnotation.min());
putInputConstraint(Input.MAX, Integer.MAX_VALUE, inputAnnotation.max());
putInputConstraint(Input.MIN_LENGTH, Integer.MIN_VALUE, inputAnnotation.minLength());
putInputConstraint(Input.MAX_LENGTH, Integer.MAX_VALUE, inputAnnotation.maxLength());
putInputConstraint(Input.STEP, 0, inputAnnotation.step());
putInputConstraint(Input.PATTERN, "", inputAnnotation.pattern());
}
this.conversionService = conversionService;
this.typeDescriptor = TypeDescriptor.nested(methodParameter, 0);
}
/**
* Creates new ActionInputParameter with default formatting conversion service.
*
* @param methodParameter
* holding metadata about the parameter
* @param value
* during sample method invocation
*/
public SpringActionInputParameter(MethodParameter methodParameter, Object value) {
this(methodParameter, value, new DefaultFormattingConversionService());
}
private void putInputConstraint(String key, Object defaultValue, Object value) {
if (!value.equals(defaultValue)) {
inputConstraints.put(key, value);
}
}
/**
* The value of the parameter at sample invocation time.
*
* @return value, may be null
*/
public Object getValue() {
return value;
}
/**
* The value of the parameter at sample invocation time, formatted according to conversion configuration.
*
* @return value, may be null
*/
public String getValueFormatted() {
String ret;
if (value == null) {
ret = null;
} else {
ret = (String) conversionService.convert(value, typeDescriptor, TypeDescriptor.valueOf(String.class));
}
return ret;
}
/**
* Gets HTML5 parameter type for input field according to {@link Type} annotation.
*
* @return the type
*/
@Override
public Type getHtmlInputFieldType() {
final Type ret;
if (inputAnnotation == null || inputAnnotation.value() == Type.FROM_JAVA) {
if (isArrayOrCollection() || isRequestBody()) {
ret = null;
} else if (DataType.isNumber(getParameterType())) {
ret = Type.NUMBER;
} else {
ret = Type.TEXT;
}
} else {
ret = inputAnnotation.value();
}
return ret;
}
public boolean isRequestBody() {
return requestBody != null;
}
public boolean isRequestParam() {
return requestParam != null;
}
public boolean isPathVariable() {
return pathVariable != null;
}
public boolean isRequestHeader() {
return requestHeader != null;
}
public boolean isInputParameter() {
return inputAnnotation != null
&& requestBody == null
&& pathVariable == null
&& requestHeader == null
&& requestParam == null;
}
@Override
public String getRequestHeaderName() {
return isRequestHeader() ? requestHeader.value() : null;
}
/**
* Has constraints defined via <code>@Input</code> annotation. Note that there might also be other kinds of
* constraints, e.g. <code>@Select</code> may define values for {@link #getPossibleValues}.
*
* @return true if parameter is constrained
*/
public boolean hasInputConstraints() {
return !inputConstraints.isEmpty();
}
public <T extends Annotation> T getAnnotation(Class<T> annotation) {
return methodParameter.getParameterAnnotation(annotation);
}
/**
* Determines if request body input parameter has a hidden input property.
*
* @param property
* name or property path
* @return true if hidden
*/
@Override
public boolean isHidden(String property) {
Annotation[] paramAnnotations = methodParameter.getParameterAnnotations();
Input inputAnnotation = methodParameter.getParameterAnnotation(Input.class);
return inputAnnotation != null && arrayContains(inputAnnotation.hidden(), property);
}
@Override
public boolean isReadOnly(String property) {
return inputAnnotation != null && (!inputAnnotation.editable() || arrayContains(inputAnnotation.readOnly(),
property));
}
@Override
public boolean isIncluded(String property) {
boolean ret;
if (inputAnnotation == null) {
ret = true;
} else {
boolean hasExplicitOrImplicitIncludes = hasExplicitOrImplicitPropertyIncludeValue();
ret = !hasExplicitOrImplicitIncludes || containsPropertyIncludeValue(property);
}
return ret;
}
/**
* Find out if property is included by searching through all annotations.
*
* @param property
* @return
*/
private boolean containsPropertyIncludeValue(String property) {
return arrayContains(inputAnnotation.readOnly(), property)
|| arrayContains(inputAnnotation.hidden(), property)
|| arrayContains(inputAnnotation.include(), property);
}
/**
* Has any explicit include value or might have implicit includes because there is a hidden or readOnly flag.
*
* @return true if explicitly or implicitly included.
*/
private boolean hasExplicitOrImplicitPropertyIncludeValue() {
// TODO maybe not a useful optimization
return inputAnnotation != null && inputAnnotation.readOnly().length > 0
|| inputAnnotation.hidden().length > 0
|| inputAnnotation.include().length > 0;
}
/**
* Determines if request body input parameter should be excluded, considering {@link Input#exclude}.
*
* @param property
* name or property path
* @return true if excluded, false if no include statement found or not excluded
*/
@Override
public boolean isExcluded(String property) {
return inputAnnotation != null && arrayContains(inputAnnotation.exclude(), property);
}
private boolean arrayContains(String[] array, String toFind) {
if (array.length == 0) {
return false;
}
for (String item : array) {
if (toFind.equals(item)) {
return true;
}
}
return false;
}
@Override
public Object[] getPossibleValues(ActionDescriptor actionDescriptor) {
return getPossibleValues(methodParameter, actionDescriptor);
}
@Override
public Object[] getPossibleValues(Method method, int parameterIndex, ActionDescriptor actionDescriptor) {
MethodParameter methodParameter = new MethodParameter(method, parameterIndex);
return getPossibleValues(methodParameter, actionDescriptor);
}
@Override
public Object[] getPossibleValues(Constructor constructor, int parameterIndex, ActionDescriptor
actionDescriptor) {
MethodParameter methodParameter = new MethodParameter(constructor, parameterIndex);
return getPossibleValues(methodParameter, actionDescriptor);
}
public Object[] getPossibleValues(MethodParameter methodParameter, ActionDescriptor actionDescriptor) {
try {
Class<?> parameterType = methodParameter.getNestedParameterType();
Object[] possibleValues;
Class<?> nested;
if (Enum[].class.isAssignableFrom(parameterType)) {
possibleValues = parameterType.getComponentType()
.getEnumConstants();
} else if (Enum.class.isAssignableFrom(parameterType)) {
possibleValues = parameterType.getEnumConstants();
} else if (Collection.class.isAssignableFrom(parameterType)
&& Enum.class.isAssignableFrom(nested = TypeDescriptor.nested(methodParameter, 1)
.getType())) {
possibleValues = nested.getEnumConstants();
} else {
Select select = methodParameter.getParameterAnnotation(Select.class);
if (select != null) {
Class<? extends Options> optionsClass = select.options();
Options options = optionsClass.newInstance();
// collect call values to pass to options.get
List<Object> from = new ArrayList<Object>();
for (String paramName : select.args()) {
ActionInputParameter parameterValue = actionDescriptor.getActionInputParameter(paramName);
if (parameterValue != null) {
from.add(parameterValue.getValue());
}
}
Object[] args = from.toArray();
possibleValues = options.get(select.value(), args);
} else {
possibleValues = new Object[0];
}
}
return possibleValues;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* Determines if action input parameter is an array or collection.
*
* @return true if array or collection
*/
public boolean isArrayOrCollection() {
if (arrayOrCollection == null) {
arrayOrCollection = DataType.isArrayOrCollection(getParameterType());
}
return arrayOrCollection;
}
/**
* Is this action input parameter required, based on the presence of a default value, the parameter annotations and
* the kind of input parameter.
*
* @return true if required
*/
public boolean isRequired() {
boolean ret;
if (isRequestBody()) {
ret = requestBody.required();
} else if (isRequestParam()) {
ret = !(isDefined(requestParam.defaultValue()) || !requestParam.required());
} else if (isRequestHeader()) {
ret = !(isDefined(requestHeader.defaultValue()) || !requestHeader.required());
} else {
ret = true;
}
return ret;
}
private boolean isDefined(String defaultValue) {
return !ValueConstants.DEFAULT_NONE.equals(defaultValue);
}
/**
* Determines default value of request param or request header, if available.
*
* @return value or null
*/
public String getDefaultValue() {
String ret;
if (isRequestParam()) {
ret = isDefined(requestParam.defaultValue()) ?
requestParam.defaultValue() : null;
} else if (isRequestHeader()) {
ret = !(ValueConstants.DEFAULT_NONE.equals(requestHeader.defaultValue())) ?
requestHeader.defaultValue() : null;
} else {
ret = null;
}
return ret;
}
/**
* Allows convenient access to multiple call values in case that this input parameter is an array or collection.
* Make sure to check {@link #isArrayOrCollection()} before calling this method.
*
* @return call values or empty array
* @throws UnsupportedOperationException
* if this input parameter is not an array or collection
*/
public Object[] getValues() {
Object[] callValues;
if (!isArrayOrCollection()) {
throw new UnsupportedOperationException("parameter is not an array or collection");
}
Object callValue = getValue();
if (callValue == null) {
callValues = new Object[0];
} else {
Class<?> parameterType = getParameterType();
if (parameterType.isArray()) {
callValues = (Object[]) callValue;
} else {
callValues = ((Collection<?>) callValue).toArray();
}
}
return callValues;
}
/**
* Was a sample call value recorded for this parameter?
*
* @return if call value is present
*/
public boolean hasValue() {
return value != null;
}
/**
* Gets parameter name of this action input parameter.
*
* @return name
*/
public String getParameterName() {
String ret;
String parameterName = methodParameter.getParameterName();
if (parameterName == null) {
methodParameter.initParameterNameDiscovery(new LocalVariableTableParameterNameDiscoverer());
ret = methodParameter.getParameterName();
} else {
ret = parameterName;
}
return ret;
}
/**
* Class which declares the method to which this input parameter belongs.
*
* @return class
*/
public Class<?> getDeclaringClass() {
return methodParameter.getDeclaringClass();
}
/**
* Type of parameter.
*
* @return type
*/
public Class<?> getParameterType() {
return methodParameter.getParameterType();
}
/**
* Generic type of parameter.
*
* @return generic type
*/
public java.lang.reflect.Type getGenericParameterType() {
return methodParameter.getGenericParameterType();
}
/**
* Gets the input constraints defined for this action input parameter.
*
* @return constraints
*/
public Map<String, Object> getInputConstraints() {
return inputConstraints;
}
@Override
public String toString() {
String kind;
if (isRequestBody()) {
kind = "RequestBody";
} else if (isPathVariable()) {
kind = "PathVariable";
} else if (isRequestParam()) {
kind = "RequestParam";
} else if (isRequestHeader()) {
kind = "RequestHeader";
} else {
kind = "nested bean property";
}
return kind + (getParameterName() != null ? " " + getParameterName() : "") + ": " + (value != null ? value
.toString() : "no value");
}
}