package de.escalon.hypermedia.spring.siren;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.annotation.Annotation;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.*;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.node.ObjectNode;
import de.escalon.hypermedia.PropertyUtils;
import de.escalon.hypermedia.action.Type;
import de.escalon.hypermedia.affordance.ActionDescriptor;
import de.escalon.hypermedia.affordance.ActionInputParameter;
import de.escalon.hypermedia.affordance.Affordance;
import de.escalon.hypermedia.affordance.DataType;
import de.escalon.hypermedia.spring.DefaultDocumentationProvider;
import de.escalon.hypermedia.spring.DocumentationProvider;
import de.escalon.hypermedia.spring.SpringActionInputParameter;
import org.springframework.core.MethodParameter;
import org.springframework.hateoas.*;
import org.springframework.hateoas.core.DefaultRelProvider;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* Maps spring-hateoas response data to siren data. Created by Dietrich on 17.04.2016.
*/
public class SirenUtils {
private static final Set<String> FILTER_RESOURCE_SUPPORT = new HashSet<String>(Arrays.asList("class", "links",
"id"));
private String requestMediaType;
private Set<String> navigationalRels = new HashSet<String>(Arrays.asList("self", "next", "previous", "prev"));
private RelProvider relProvider = new DefaultRelProvider();
private DocumentationProvider documentationProvider = new DefaultDocumentationProvider();
public void toSirenEntity(SirenEntityContainer objectNode, Object object) {
if (object == null) {
return;
}
try {
if (object instanceof Resource) {
Resource<?> resource = (Resource<?>) object;
objectNode.setLinks(this.toSirenLinks(
getNavigationalLinks(resource.getLinks())));
objectNode.setEmbeddedLinks(this.toSirenEmbeddedLinks(
getEmbeddedLinks(resource.getLinks())));
objectNode.setActions(this.toSirenActions(getActions(resource.getLinks())));
toSirenEntity(objectNode, resource.getContent());
return;
} else if (object instanceof Resources) {
Resources<?> resources = (Resources<?>) object;
objectNode.setLinks(this.toSirenLinks(getNavigationalLinks(resources.getLinks())));
Collection<?> content = resources.getContent();
toSirenEntity(objectNode, content);
objectNode.setActions(this.toSirenActions(getActions(resources.getLinks())));
return;
} else if (object instanceof ResourceSupport) {
ResourceSupport resource = (ResourceSupport) object;
objectNode.setLinks(this.toSirenLinks(
getNavigationalLinks(resource.getLinks())));
objectNode.setEmbeddedLinks(this.toSirenEmbeddedLinks(
getEmbeddedLinks(resource.getLinks())));
objectNode.setActions(this.toSirenActions(
getActions(resource.getLinks())));
// wrap object attributes below to avoid endless loop
} else if (object instanceof Collection) {
Collection<?> collection = (Collection<?>) object;
for (Object item : collection) {
SirenEmbeddedRepresentation child = new SirenEmbeddedRepresentation();
toSirenEntity(child, item);
objectNode.addSubEntity(child);
}
return;
}
if (object instanceof Map) {
Map<?, ?> map = (Map<?, ?>) object;
Map<String, Object> propertiesNode = new HashMap<String, Object>();
objectNode.setProperties(propertiesNode);
for (Map.Entry<?, ?> entry : map.entrySet()) {
String key = entry.getKey()
.toString();
Object content = entry.getValue();
String docUrl = documentationProvider.getDocumentationUrl(key, content);
traverseAttribute(objectNode, propertiesNode, key, docUrl, content);
}
} else { // bean or ResourceSupport
objectNode.setSirenClasses(getSirenClasses(object));
Map<String, Object> propertiesNode = new HashMap<String, Object>();
createRecursiveSirenEntitiesFromPropertiesAndFields(objectNode, propertiesNode, object);
objectNode.setProperties(propertiesNode);
}
} catch (Exception ex) {
throw new RuntimeException("failed to transform object " + object, ex);
}
}
private List<String> getSirenClasses(Object object) {
List<String> sirenClasses;
String sirenClass = relProvider.getItemResourceRelFor(object.getClass());
if (sirenClass != null) {
sirenClasses = Collections.singletonList(sirenClass);
} else {
sirenClasses = Collections.emptyList();
}
return sirenClasses;
}
private List<Link> getEmbeddedLinks(List<Link> links) {
List<Link> ret = new ArrayList<Link>();
for (Link link : links) {
if (!navigationalRels.contains(link.getRel())) {
if (link instanceof Affordance) {
Affordance affordance = (Affordance) link;
List<ActionDescriptor> actionDescriptors = affordance.getActionDescriptors();
for (ActionDescriptor actionDescriptor : actionDescriptors) {
if ("GET".equals(actionDescriptor.getHttpMethod()) && !affordance.isTemplated()) {
ret.add(link);
}
}
} else {
// templated links are actions, not embedded links
if(!link.isTemplated()) {
ret.add(link);
}
}
}
}
return ret;
}
private List<Link> getNavigationalLinks(List<Link> links) {
List<Link> ret = new ArrayList<Link>();
for (Link link : links) {
if (navigationalRels.contains(link.getRel())) {
ret.add(link);
}
}
return ret;
}
private List<Link> getActions(List<Link> links) {
List<Link> ret = new ArrayList<Link>();
for (Link link : links) {
if (link instanceof Affordance) {
Affordance affordance = (Affordance) link;
List<ActionDescriptor> actionDescriptors = affordance.getActionDescriptors();
for (ActionDescriptor actionDescriptor : actionDescriptors) {
// non-self GET non-GET and templated links are actions
if (!("GET".equals(actionDescriptor.getHttpMethod())) || affordance.isTemplated()) {
ret.add(link);
// add just once for eligible link
break;
}
}
} else {
// templated links are actions
if (!navigationalRels.contains(link.getRel()) && link.isTemplated()) {
ret.add(link);
}
}
}
return ret;
}
private void createRecursiveSirenEntitiesFromPropertiesAndFields(SirenEntityContainer objectNode, Map<String,
Object> propertiesNode,
Object object) throws InvocationTargetException,
IllegalAccessException {
Map<String, PropertyDescriptor> propertyDescriptors = PropertyUtils.getPropertyDescriptors(object);
for (PropertyDescriptor propertyDescriptor : propertyDescriptors.values()) {
String name = propertyDescriptor.getName();
if (FILTER_RESOURCE_SUPPORT.contains(name)) {
continue;
}
Method readMethod = propertyDescriptor.getReadMethod();
if (readMethod != null) {
Object content = readMethod
.invoke(object);
String docUrl = documentationProvider.getDocumentationUrl(readMethod, content);
traverseAttribute(objectNode, propertiesNode, name, docUrl, content);
}
}
Field[] fields = object.getClass()
.getFields();
for (Field field : fields) {
String name = field.getName();
if (!propertyDescriptors.containsKey(name)) {
Object content = field.get(object);
String docUrl = documentationProvider.getDocumentationUrl(field, content);
traverseAttribute(objectNode, propertiesNode, name, docUrl, content);
}
}
}
private void traverseAttribute(SirenEntityContainer objectNode, Map<String, Object> propertiesNode,
String name, String docUrl, Object content) throws
InvocationTargetException, IllegalAccessException {
Object value = getContentAsScalarValue(content);
if (value != NULL_VALUE) {
if (value != null) {
// for each scalar property of a simple bean, add valuepair
propertiesNode.put(name, value);
} else {
if (content instanceof Resources) {
toSirenEntity(objectNode, content);
} else if (content instanceof ResourceSupport) {
traverseSingleSubEntity(objectNode, content, name, docUrl);
} else if (content instanceof Collection) {
Collection<?> collection = (Collection<?>) content;
for (Object item : collection) {
if (DataType.isSingleValueType(item.getClass())) {
Object listObject = propertiesNode.get(name);
if (listObject == null) {
listObject = new ArrayList();
propertiesNode.put(name, listObject);
}
if (listObject instanceof Collection) {
((Collection) listObject).add(item);
}
} else if (item != null) {
traverseSingleSubEntity(objectNode, item, name, docUrl);
}
}
} else if (content instanceof Map) {
Set<Map.Entry<String, Object>> entries = ((Map<String, Object>) content).entrySet();
Map<String, Object> subProperties = new HashMap<String, Object>();
propertiesNode.put(name, subProperties);
for (Map.Entry<String, Object> entry : entries) {
traverseAttribute(objectNode, subProperties, entry.getKey(), docUrl, entry.getValue());
}
} else {
Map<String, Object> nestedProperties = new HashMap<String, Object>();
propertiesNode.put(name, nestedProperties);
createRecursiveSirenEntitiesFromPropertiesAndFields(objectNode, nestedProperties, content);
}
}
}
}
private void traverseSingleSubEntity(SirenEntityContainer objectNode, Object content,
String name, String docUrl)
throws InvocationTargetException, IllegalAccessException {
Object bean;
List<Link> links;
if (content instanceof Resource) {
bean = ((Resource) content).getContent();
links = ((Resource) content).getLinks();
} else if (content instanceof ResourceSupport) {
bean = content;
links = ((ResourceSupport) content).getLinks();
} else {
bean = content;
links = Collections.emptyList();
}
Map<String, Object> properties = new HashMap<String, Object>();
List<String> rels = Collections.singletonList(docUrl != null ? docUrl : name);
SirenEmbeddedRepresentation subEntity = new SirenEmbeddedRepresentation(
getSirenClasses(bean), properties, null, toSirenActions(getActions(links)),
toSirenLinks(getNavigationalLinks(links)), rels, null);
//subEntity.setProperties(properties);
objectNode.addSubEntity(subEntity);
List<SirenEmbeddedLink> sirenEmbeddedLinks = toSirenEmbeddedLinks(getEmbeddedLinks(links));
for (SirenEmbeddedLink sirenEmbeddedLink : sirenEmbeddedLinks) {
subEntity.addSubEntity(sirenEmbeddedLink);
}
createRecursiveSirenEntitiesFromPropertiesAndFields(subEntity, properties, bean);
}
private List<SirenAction> toSirenActions(List<Link> links) {
List<SirenAction> ret = new ArrayList<SirenAction>();
for (Link link : links) {
if (link instanceof Affordance) {
Affordance affordance = (Affordance) link;
List<ActionDescriptor> actionDescriptors = affordance.getActionDescriptors();
for (ActionDescriptor actionDescriptor : actionDescriptors) {
List<SirenField> fields = toSirenFields(actionDescriptor);
// TODO integrate getActions and this method so we do not need this check:
// only templated affordances or non-get affordances are actions
if (!"GET".equals(actionDescriptor.getHttpMethod()) || affordance.isTemplated()) {
String href;
if (affordance.isTemplated()) {
href = affordance.getUriTemplateComponents()
.getBaseUri();
} else {
href = affordance.getHref();
}
SirenAction sirenAction = new SirenAction(null, actionDescriptor.getActionName(), null,
actionDescriptor.getHttpMethod(), href, requestMediaType, fields);
ret.add(sirenAction);
}
}
} else if (link.isTemplated()) {
List<SirenField> fields = new ArrayList<SirenField>();
List<TemplateVariable> variables = link.getVariables();
boolean queryOnly = false;
for (TemplateVariable variable : variables) {
queryOnly = isQueryParam(variable);
if (!queryOnly) {
break;
}
fields.add(new SirenField(variable.getName(), "text", (String) null, variable.getDescription(),
null));
}
// no support for non-query fields in siren
if (queryOnly) {
String baseUri = new UriTemplate(link.getHref()).expand()
.toASCIIString();
SirenAction sirenAction = new SirenAction(null, null, null, "GET",
baseUri, null, fields);
ret.add(sirenAction);
}
}
}
return ret;
}
private boolean isQueryParam(TemplateVariable variable) {
boolean queryOnly;
switch (variable.getType()) {
case REQUEST_PARAM:
case REQUEST_PARAM_CONTINUED:
queryOnly = true;
break;
default:
queryOnly = false;
}
return queryOnly;
}
private List<SirenField> toSirenFields(ActionDescriptor actionDescriptor) {
List<SirenField> ret = new ArrayList<SirenField>();
if (actionDescriptor.hasRequestBody()) {
recurseBeanCreationParams(ret, actionDescriptor.getRequestBody()
.getParameterType(), actionDescriptor, actionDescriptor.getRequestBody(), actionDescriptor
.getRequestBody()
.getValue(), "", Collections.<String>emptySet());
} else {
Collection<String> paramNames = actionDescriptor.getRequestParamNames();
for (String paramName : paramNames) {
ActionInputParameter inputParameter = actionDescriptor.getActionInputParameter(paramName);
Object[] possibleValues = inputParameter.getPossibleValues(actionDescriptor);
ret.add(createSirenField(paramName, inputParameter.getValueFormatted(), inputParameter,
possibleValues));
}
}
return ret;
}
/**
* Renders input fields for bean properties of bean to add or update or patch.
*
* @param sirenFields to add to
* @param beanType to render
* @param annotatedParameters which describes the method
* @param annotatedParameter which requires the bean
* @param currentCallValue sample call value
*/
private void recurseBeanCreationParams(List<SirenField> sirenFields, Class<?> beanType,
ActionDescriptor annotatedParameters,
ActionInputParameter annotatedParameter, Object currentCallValue,
String parentParamName, Set<String> knownFields) {
// TODO collection, map and object node creation are only describable by an annotation, not via type reflection
if (ObjectNode.class.isAssignableFrom(beanType) || Map.class.isAssignableFrom(beanType)
|| Collection.class.isAssignableFrom(beanType) || beanType.isArray()) {
return; // use @Input(include) to list parameter names, at least? Or mix with hdiv's form builder?
}
try {
Constructor[] constructors = beanType.getConstructors();
// find default ctor
Constructor constructor = PropertyUtils.findDefaultCtor(constructors);
// find ctor with JsonCreator ann
if (constructor == null) {
constructor = PropertyUtils.findJsonCreator(constructors, JsonCreator.class);
}
Assert.notNull(constructor, "no default constructor or JsonCreator found for type " + beanType
.getName());
int parameterCount = constructor.getParameterTypes().length;
if (parameterCount > 0) {
Annotation[][] annotationsOnParameters = constructor.getParameterAnnotations();
Class[] parameters = constructor.getParameterTypes();
int paramIndex = 0;
for (Annotation[] annotationsOnParameter : annotationsOnParameters) {
for (Annotation annotation : annotationsOnParameter) {
if (JsonProperty.class == annotation.annotationType()) {
JsonProperty jsonProperty = (JsonProperty) annotation;
// TODO use required attribute of JsonProperty for required fields
String paramName = jsonProperty.value();
Class parameterType = parameters[paramIndex];
Object propertyValue = PropertyUtils.getPropertyOrFieldValue(currentCallValue,
paramName);
MethodParameter methodParameter = new MethodParameter(constructor, paramIndex);
addSirenFieldsForMethodParameter(sirenFields, methodParameter, annotatedParameter,
annotatedParameters,
parentParamName, paramName, parameterType, propertyValue,
knownFields);
paramIndex++; // increase for each @JsonProperty
}
}
}
Assert.isTrue(parameters.length == paramIndex,
"not all constructor arguments of @JsonCreator " + constructor.getName() +
" are annotated with @JsonProperty");
}
Set<String> knownConstructorFields = new HashSet<String>(sirenFields.size());
for (SirenField sirenField : sirenFields) {
knownConstructorFields.add(sirenField.getName());
}
// TODO support Option provider by other method args?
Map<String, PropertyDescriptor> propertyDescriptors = PropertyUtils.getPropertyDescriptors(beanType);
// add input field for every setter
for (PropertyDescriptor propertyDescriptor : propertyDescriptors.values()) {
final Method writeMethod = propertyDescriptor.getWriteMethod();
String propertyName = propertyDescriptor.getName();
if (writeMethod == null || knownFields.contains(parentParamName + propertyName)) {
continue;
}
final Class<?> propertyType = propertyDescriptor.getPropertyType();
Object propertyValue = PropertyUtils.getPropertyOrFieldValue(currentCallValue, propertyName);
MethodParameter methodParameter = new MethodParameter(propertyDescriptor.getWriteMethod(), 0);
addSirenFieldsForMethodParameter(sirenFields, methodParameter, annotatedParameter,
annotatedParameters,
parentParamName, propertyName, propertyType, propertyValue, knownConstructorFields);
}
} catch (Exception e) {
throw new RuntimeException("Failed to write input fields for constructor", e);
}
}
private void addSirenFieldsForMethodParameter(List<SirenField> sirenFields, MethodParameter
methodParameter, ActionInputParameter annotatedParameter, ActionDescriptor annotatedParameters, String
parentParamName, String paramName, Class
parameterType, Object propertyValue, Set<String>
knownFields) {
if (DataType.isSingleValueType(parameterType)
|| DataType.isArrayOrCollection(parameterType)) {
if (annotatedParameter.isIncluded(paramName) && !knownFields.contains(parentParamName + paramName)) {
ActionInputParameter constructorParamInputParameter =
new SpringActionInputParameter(methodParameter, propertyValue);
final Object[] possibleValues =
annotatedParameter.getPossibleValues(methodParameter, annotatedParameters);
// dot-separated property path as field name
SirenField sirenField = createSirenField(parentParamName + paramName,
propertyValue, constructorParamInputParameter, possibleValues);
sirenFields.add(sirenField);
}
} else {
Object callValueBean;
if (propertyValue instanceof Resource) {
callValueBean = ((Resource) propertyValue).getContent();
} else {
callValueBean = propertyValue;
}
recurseBeanCreationParams(sirenFields, parameterType, annotatedParameters,
annotatedParameter,
callValueBean, paramName + ".", knownFields);
}
}
private SirenField createSirenField(String paramName, Object propertyValue,
ActionInputParameter inputParameter, Object[] possibleValues) {
SirenField sirenField;
if (possibleValues.length == 0) {
String propertyValueAsString = propertyValue == null ? null : propertyValue
.toString();
Type htmlInputFieldType = inputParameter.getHtmlInputFieldType();
// TODO: null -> array or bean parameter without possible values
String type = htmlInputFieldType == null ? "text" :
htmlInputFieldType
.name()
.toLowerCase();
sirenField = new SirenField(paramName,
type,
propertyValueAsString, null, null);
} else {
List<SirenFieldValue> sirenPossibleValues = new ArrayList<SirenFieldValue>();
String type;
if (inputParameter.isArrayOrCollection()) {
type = "checkbox";
for (Object possibleValue : possibleValues) {
boolean selected = ObjectUtils.containsElement(
inputParameter.getValues(),
possibleValue);
// TODO have more useful value title
sirenPossibleValues.add(new SirenFieldValue(possibleValue.toString(), possibleValue, selected));
}
} else {
type = "radio";
for (Object possibleValue : possibleValues) {
boolean selected = possibleValue.equals(propertyValue);
sirenPossibleValues.add(new SirenFieldValue(possibleValue.toString(), possibleValue, selected));
}
}
sirenField = new SirenField(paramName,
type,
sirenPossibleValues, null, null);
}
return sirenField;
}
private List<SirenLink> toSirenLinks(List<Link> links) {
List<SirenLink> ret = new ArrayList<SirenLink>();
for (Link link : links) {
if (link instanceof Affordance) {
ret.add(new SirenLink(null, ((Affordance) link).getRels(), link.getHref(), null, null));
} else {
ret.add(new SirenLink(null, Collections.singletonList(link.getRel()), link.getHref(), null, null));
}
}
return ret;
}
private List<SirenEmbeddedLink> toSirenEmbeddedLinks(List<Link> links) {
List<SirenEmbeddedLink> ret = new ArrayList<SirenEmbeddedLink>();
for (Link link : links) {
if (link instanceof Affordance) {
// TODO: how to determine classes? type of target resource? collection/item?
ret.add(new SirenEmbeddedLink(null, ((Affordance) link).getRels(), link
.getHref(), null, null));
} else {
ret.add(new SirenEmbeddedLink(null, Collections.singletonList(link.getRel()), link
.getHref(), null, null));
}
}
return ret;
}
static class NullValue {
}
public static final NullValue NULL_VALUE = new NullValue();
private Object getContentAsScalarValue(Object content) {
Object value = null;
if (content == null) {
value = NULL_VALUE;
} else if (DataType.isSingleValueType(content.getClass())) {
value = DataType.asScalarValue(content);
}
return value;
}
public void setRequestMediaType(String requestMediaType) {
this.requestMediaType = requestMediaType;
}
public void setRelProvider(RelProvider relProvider) {
this.relProvider = relProvider;
}
public void setDocumentationProvider(DocumentationProvider documentationProvider) {
this.documentationProvider = documentationProvider;
}
public void setAdditionalNavigationalRels(Collection<String> additionalNavigationalRels) {
this.navigationalRels.addAll(additionalNavigationalRels);
}
}