/* * Copyright (c) 2015. 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.uber; 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.*; import de.escalon.hypermedia.spring.SpringActionDescriptor; import de.escalon.hypermedia.spring.SpringActionInputParameter; import org.springframework.core.MethodParameter; import org.springframework.hateoas.Link; import org.springframework.hateoas.Resource; import org.springframework.hateoas.ResourceSupport; import org.springframework.hateoas.Resources; import org.springframework.util.Assert; import org.springframework.web.bind.annotation.RequestMethod; import java.beans.PropertyDescriptor; import java.lang.annotation.Annotation; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.*; import java.util.Map.Entry; public class UberUtils { private UberUtils() { } static final Set<String> FILTER_RESOURCE_SUPPORT = new HashSet<String>(Arrays.asList("class", "links", "id")); static final String MODEL_FORMAT = "%s={%s}"; /** * Recursively converts object to nodes of uber data. * * @param objectNode * to append to * @param object * to convert */ public static void toUberData(AbstractUberNode objectNode, Object object) { Set<String> filtered = FILTER_RESOURCE_SUPPORT; if (object == null) { return; } try { // TODO: move all returns to else branch of property descriptor handling if (object instanceof Resource) { Resource<?> resource = (Resource<?>) object; objectNode.addLinks(resource.getLinks()); toUberData(objectNode, resource.getContent()); return; } else if (object instanceof Resources) { Resources<?> resources = (Resources<?>) object; // TODO set name using EVO see HypermediaSupportBeanDefinitionRegistrar objectNode.addLinks(resources.getLinks()); Collection<?> content = resources.getContent(); toUberData(objectNode, content); return; } else if (object instanceof ResourceSupport) { ResourceSupport resource = (ResourceSupport) object; objectNode.addLinks(resource.getLinks()); // wrap object attributes below to avoid endless loop } else if (object instanceof Collection) { Collection<?> collection = (Collection<?>) object; for (Object item : collection) { // TODO name must be repeated for each collection item UberNode itemNode = new UberNode(); objectNode.addData(itemNode); toUberData(itemNode, item); } return; } if (object instanceof Map) { Map<?, ?> map = (Map<?, ?>) object; for (Entry<?, ?> entry : map.entrySet()) { String key = entry.getKey() .toString(); Object content = entry.getValue(); Object value = getContentAsScalarValue(content); UberNode entryNode = new UberNode(); objectNode.addData(entryNode); entryNode.setName(key); if (value != null) { entryNode.setValue(value); } else { toUberData(entryNode, content); } } } else { Map<String, PropertyDescriptor> propertyDescriptors = PropertyUtils.getPropertyDescriptors(object); for (PropertyDescriptor propertyDescriptor : propertyDescriptors.values()) { String name = propertyDescriptor.getName(); if (filtered.contains(name)) { continue; } UberNode propertyNode = new UberNode(); Object content = propertyDescriptor.getReadMethod() .invoke(object); if (isEmptyCollectionOrMap(content, propertyDescriptor.getPropertyType())) { continue; } Object value = getContentAsScalarValue(content); propertyNode.setName(name); objectNode.addData(propertyNode); if (value != null) { // for each scalar property of a simple bean, add valuepair nodes to data propertyNode.setValue(value); } else { toUberData(propertyNode, content); } } Field[] fields = object.getClass() .getFields(); for (Field field : fields) { String name = field.getName(); if (!propertyDescriptors.containsKey(name)) { Object content = field.get(object); Class<?> type = field.getType(); if (isEmptyCollectionOrMap(content, type)) { continue; } UberNode propertyNode = new UberNode(); Object value = getContentAsScalarValue(content); propertyNode.setName(name); objectNode.addData(propertyNode); if (value != null) { // for each scalar property of a simple bean, add valuepair nodes to data propertyNode.setValue(value); } else { toUberData(propertyNode, content); } } } } } catch (Exception ex) { throw new RuntimeException("failed to transform object " + object, ex); } } private static boolean isEmptyCollectionOrMap(Object content, Class<?> type) { if (Collection.class.isAssignableFrom(type)) { if (content == null) { return true; } else { if (((List) content).isEmpty()) { return true; } } } else if (Map.class.isAssignableFrom(type)) { if (content == null) { return true; } else { if (((List) content).isEmpty()) { return true; } } } return false; } private static Object getContentAsScalarValue(Object content) { final Object value; if (content == null) { value = UberNode.NULL_VALUE; } else if (DataType.isSingleValueType(content.getClass())) { value = content.toString(); } else { value = null; } return value; } /** * Converts single link to uber node. * * @param href * to use * @param actionDescriptor * to use for action and model, never null * @param rels * of the link * @return uber link */ public static UberNode toUberLink(String href, ActionDescriptor actionDescriptor, String... rels) { return toUberLink(href, actionDescriptor, Arrays.asList(rels)); } /** * Converts single link to uber node. * * @param href * to use * @param actionDescriptor * to use for action and model, never null * @param rels * of the link * @return uber link */ public static UberNode toUberLink(String href, ActionDescriptor actionDescriptor, List<String> rels) { Assert.notNull(actionDescriptor, "actionDescriptor must not be null"); UberNode uberLink = new UberNode(); uberLink.setRel(rels); PartialUriTemplateComponents partialUriTemplateComponents = new PartialUriTemplate(href).expand(Collections .<String, Object>emptyMap()); uberLink.setUrl(partialUriTemplateComponents.toString()); uberLink.setTemplated(partialUriTemplateComponents.hasVariables() ? Boolean.TRUE : null); uberLink.setModel(getModelProperty(href, actionDescriptor)); if (actionDescriptor != null) { RequestMethod requestMethod = RequestMethod.valueOf(actionDescriptor.getHttpMethod()); uberLink.setAction(UberAction.forRequestMethod(requestMethod)); } return uberLink; } private static String getModelProperty(String href, ActionDescriptor actionDescriptor) { RequestMethod httpMethod = RequestMethod.valueOf(actionDescriptor.getHttpMethod()); StringBuffer model = new StringBuffer(); switch (httpMethod) { case POST: case PUT: case PATCH: { List<UberField> uberFields = new ArrayList<UberField>(); recurseBeanCreationParams(uberFields, actionDescriptor.getRequestBody() .getParameterType(), actionDescriptor, actionDescriptor.getRequestBody(), actionDescriptor .getRequestBody() .getValue(), "", Collections.<String>emptySet()); for (UberField uberField : uberFields) { if (model.length() > 0) { model.append("&"); } model.append(String.format(MODEL_FORMAT, uberField.getName(), uberField.getName())); } break; } default: } return model.length() == 0 ? null : model.toString(); } // private List<SirenAction> toUberActions(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 = toUberFields(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 List<SirenField> toUberFields(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 uberFields * 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 static void recurseBeanCreationParams(List<UberField> uberFields, 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); addUberFieldsForMethodParameter(uberFields, 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>(uberFields.size()); for (UberField sirenField : uberFields) { 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); addUberFieldsForMethodParameter(uberFields, methodParameter, annotatedParameter, annotatedParameters, parentParamName, propertyName, propertyType, propertyValue, knownConstructorFields); } } catch (Exception e) { throw new RuntimeException("Failed to write input fields for constructor", e); } } public static List<ActionDescriptor> getActionDescriptors(Link link) { List<ActionDescriptor> actionDescriptors; if (link instanceof Affordance) { actionDescriptors = ((Affordance) link).getActionDescriptors(); } else { SpringActionDescriptor actionDescriptor = new SpringActionDescriptor("get", RequestMethod.GET .name()); PartialUriTemplate partialUriTemplate = new PartialUriTemplate(link.getHref()); PartialUriTemplateComponents parts = partialUriTemplate.asComponents(); actionDescriptors = Arrays.asList((ActionDescriptor) actionDescriptor); } return actionDescriptors; } public static List<String> getRels(Link link) { List<String> rels; if (link instanceof Affordance) { rels = ((Affordance) link).getRels(); } else { rels = Arrays.asList(link.getRel()); } return rels; } private static void addUberFieldsForMethodParameter(List<UberField> fields, 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 UberField field = createUberField(parentParamName + paramName, propertyValue, constructorParamInputParameter, possibleValues); fields.add(field); } } else { Object callValueBean; if (propertyValue instanceof Resource) { callValueBean = ((Resource) propertyValue).getContent(); } else { callValueBean = propertyValue; } recurseBeanCreationParams(fields, parameterType, annotatedParameters, annotatedParameter, callValueBean, paramName + ".", knownFields); } } private static UberField createUberField(String paramName, Object propertyValue, ActionInputParameter inputParameter, Object[] possibleValues) { UberField field; // 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(); field = new UberField(paramName, propertyValueAsString); // } 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)); // } // } // field = new UberField(paramName, // sirenPossibleValues); // } return field; } }