package de.escalon.hypermedia.spring.xhtml; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import de.escalon.hypermedia.PropertyUtils; import de.escalon.hypermedia.action.Input; 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.core.convert.Property; import org.springframework.hateoas.Link; import org.springframework.hateoas.TemplateVariable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; import org.springframework.web.bind.annotation.RequestMethod; import java.beans.BeanInfo; import java.beans.IntrospectionException; import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.io.IOException; import java.io.Writer; import java.lang.annotation.Annotation; import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.util.*; import static de.escalon.hypermedia.spring.xhtml.XhtmlWriter.OptionalAttributes.attr; /** * Created by Dietrich on 09.02.2015. */ public class XhtmlWriter extends Writer { private Writer writer; private List<String> stylesheets = Collections.emptyList(); public static final String HTML_HEAD_START = "" + // //"<?xml version='1.0' encoding='UTF-8' ?>" + // formatter "<!DOCTYPE html>" + // "<html xmlns='http://www.w3.org/1999/xhtml'>" + // " <head>" + // " <meta charset=\"utf-8\"/>" + // " <title>%s</title>"; public static final String HTML_STYLESHEET = "" + // " <link rel=\"stylesheet\" href=\"%s\" />"; public static final String HTML_HEAD_END = "" + // " </head>" + // " <body>" + // " <div class=\"container\">\n" + // " <div class=\"row\">"; public static final String HTML_END = "" + // " </div>" + " </div>" + " </body>" + // "</html>"; private String methodParam = "_method"; private DocumentationProvider documentationProvider = new DefaultDocumentationProvider(); private String formControlClass = "form-control"; private String formGroupClass = "form-group"; private String controlLabelClass = "control-label"; public XhtmlWriter(Writer writer) { this.writer = writer; } public void setMethodParam(String methodParam) { this.methodParam = methodParam; } public void beginHtml(String title) throws IOException { write(String.format(HTML_HEAD_START, title)); for (String stylesheet : stylesheets) { write(String.format(HTML_STYLESHEET, stylesheet)); } write(String.format(HTML_HEAD_END, title)); } public void endHtml() throws IOException { write(HTML_END); } public void beginDiv() throws IOException { writer.write("<div>"); } public void beginDiv(OptionalAttributes attributes) throws IOException { writer.write("<div "); writeAttributes(attributes); endTag(); } public void endDiv() throws IOException { writer.write("</div>"); } @Override public void write(char[] cbuf, int off, int len) throws IOException { writer.write(cbuf, off, len); } @Override public void flush() throws IOException { writer.flush(); } @Override public void close() throws IOException { writer.close(); } public void beginUnorderedList() throws IOException { writer.write("<ul class=\"list-group\">"); } public void endUnorderedList() throws IOException { writer.write("</ul>"); } public void beginListItem() throws IOException { writer.write("<li class=\"list-group-item\">"); } public void endListItem() throws IOException { writer.write("</li>"); } public void beginSpan() throws IOException { writer.write("<span>"); } public void endSpan() throws IOException { writer.write("</span>"); } public void beginDl() throws IOException { // TODO: make this configurable? writer.write("<dl >"); } public void endDl() throws IOException { writer.write("</dl>"); } public void beginDt() throws IOException { writer.write("<dt>"); } public void endDt() throws IOException { writer.write("</dt>"); } public void beginDd() throws IOException { writer.write("<dd>"); } public void endDd() throws IOException { writer.write("</dd>"); } public void writeSpan(Object value) throws IOException { beginSpan(); writer.write(value.toString()); endSpan(); } public void writeDefinitionTerm(Object value) throws IOException { beginDt(); writer.write(value.toString()); endDt(); } public void setStylesheets(List<String> stylesheets) { Assert.notNull(stylesheets); this.stylesheets = stylesheets; } public void setDocumentationProvider(DocumentationProvider documentationProvider) { this.documentationProvider = documentationProvider; } public static class OptionalAttributes { private Map<String, String> attributes = new LinkedHashMap<String, String>(); @Override public String toString() { return attributes.toString(); } /** * Creates OptionalAttributes with one optional attribute having name if value is not null. * * @param name * of first attribute * @param value * may be null * @return builder with one attribute, attr builder if value is null */ public static OptionalAttributes attr(String name, String value) { Assert.isTrue(name != null && value != null || value == null); OptionalAttributes attributeBuilder = new OptionalAttributes(); addAttributeIfValueNotNull(name, value, attributeBuilder); return attributeBuilder; } private static void addAttributeIfValueNotNull(String name, String value, OptionalAttributes attributeBuilder) { if (value != null) { attributeBuilder.attributes.put(name, value); } } public OptionalAttributes and(String name, String value) { addAttributeIfValueNotNull(name, value, this); return this; } public Map<String, String> build() { return attributes; } /** * Creates OptionalAttributes builder. * * @return builder */ public static OptionalAttributes attr() { return attr(null, null); } } public void writeLinks(List<Link> links) throws IOException { for (Link link : links) { if (link instanceof Affordance) { Affordance affordance = (Affordance) link; List<ActionDescriptor> actionDescriptors = affordance.getActionDescriptors(); if (actionDescriptors.isEmpty()) { // treat like simple link appendLinkWithoutActionDescriptor(affordance); } else { if (affordance.isTemplated()) { // TODO ensure that template expansion always takes place for base uri if (!affordance.isBaseUriTemplated()) { for (ActionDescriptor actionDescriptor : actionDescriptors) { RequestMethod httpMethod = RequestMethod.valueOf(actionDescriptor.getHttpMethod()); // html does not allow templated action attr for forms, only render GET form if (RequestMethod.GET == httpMethod) { // TODO: partial uritemplate query must become hidden field appendForm(affordance, actionDescriptor); } // TODO write human-readable description of additional methods? } } } else { for (ActionDescriptor actionDescriptor : actionDescriptors) { // TODO write documentation about the supported action and maybe fields? if ("GET".equals(actionDescriptor.getHttpMethod()) && actionDescriptor.getRequestParamNames() .isEmpty()) { beginDiv(); // GET without params is simple <a href> writeAnchor(OptionalAttributes.attr("href", affordance.expand() .getHref()) .and("rel", affordance.getRel()), affordance.getRel()); endDiv(); } else { appendForm(affordance, actionDescriptor); } } } } } else { // simple link, may be templated appendLinkWithoutActionDescriptor(link); } } } /** * Appends form and squashes non-GET or POST to POST. If required, adds _method field for handling by an * appropriate * filter such as Spring's HiddenHttpMethodFilter. * * @param affordance * to make into a form * @param actionDescriptor * describing the form action * @throws IOException * @see * * <a href="http://docs.spring.io/spring/docs/3.0 .x/javadoc-api/org/springframework/web/filter/HiddenHttpMethodFilter.html">Spring * MVC HiddenHttpMethodFilter</a> */ private void appendForm(Affordance affordance, ActionDescriptor actionDescriptor) throws IOException { String formName = actionDescriptor.getActionName(); RequestMethod httpMethod = RequestMethod.valueOf(actionDescriptor.getHttpMethod()); // Link's expand method removes non-required variables from URL String actionUrl = affordance.expand() .getHref(); beginForm(OptionalAttributes.attr("action", actionUrl) .and("method", getHtmlConformingHttpMethod(httpMethod)) .and("name", formName)); write("<h4>"); write("Form " + formName); write("</h4>"); writeHiddenHttpMethodField(httpMethod); // build the form if (actionDescriptor.hasRequestBody()) { // parameter bean ActionInputParameter requestBody = actionDescriptor.getRequestBody(); Class<?> parameterType = requestBody.getParameterType(); recurseBeanProperties(parameterType, actionDescriptor, requestBody, requestBody.getValue(), ""); } else { // plain parameter list Collection<String> requestParams = actionDescriptor.getRequestParamNames(); for (String requestParamName : requestParams) { ActionInputParameter actionInputParameter = actionDescriptor.getActionInputParameter(requestParamName); Object[] possibleValues = actionInputParameter.getPossibleValues(actionDescriptor); // TODO duplication with appendInputOrSelect if (possibleValues.length > 0) { if (actionInputParameter.isArrayOrCollection()) { appendSelectMulti(requestParamName, possibleValues, actionInputParameter); } else { appendSelectOne(requestParamName, possibleValues, actionInputParameter); } } else { if (actionInputParameter.isArrayOrCollection()) { // have as many inputs as there are call values, list of 5 nulls gives you five input fields // TODO support for free list input instead, code on demand? Object[] callValues = actionInputParameter.getValues(); int items = callValues.length; for (int i = 0; i < items; i++) { Object value; if (i < callValues.length) { value = callValues[i]; } else { value = null; } appendInput(requestParamName, actionInputParameter, value, actionInputParameter .isReadOnly(requestParamName)); // not readonly } } else { String callValueFormatted = actionInputParameter.getValueFormatted(); appendInput(requestParamName, actionInputParameter, callValueFormatted, actionInputParameter .isReadOnly(requestParamName)); // not readonly } } } } inputButton(Type.SUBMIT, capitalize(httpMethod.name() .toLowerCase())); endForm(); } private void appendLinkWithoutActionDescriptor(Link link) throws IOException { if (link.isTemplated()) { // TODO ensure that template expansion takes place for base uri Link expanded = link.expand(); // remove query variables beginForm(OptionalAttributes.attr("action", expanded.getHref()) .and("method", "GET")); List<TemplateVariable> variables = link.getVariables(); for (TemplateVariable variable : variables) { String variableName = variable.getName(); String label = variable.hasDescription() ? variable.getDescription() : variableName; writeLabelWithDoc(label, variableName, null); // no documentation url input(variableName, Type.TEXT); } } else { String rel = link.getRel(); String title = (rel != null ? rel : link.getHref()); // TODO: write html <link> instead of anchor <a> here? writeAnchor(OptionalAttributes.attr("href", link.getHref()) .and("rel", link.getRel()), title); } } /** * Classic submit or reset button. * * @param type * submit or reset * @param value * caption on the button * @throws IOException */ private void inputButton(Type type, String value) throws IOException { write("<input type=\""); write(type.toString()); write("\" "); write("value"); write("="); quote(); write(value); quote(); write("/>"); } private void input(String fieldName, Type type, OptionalAttributes attributes) throws IOException { write("<input name=\""); write(fieldName); write("\" type=\""); write(type.toString()); write("\" class=\""); write(formControlClass); write("\" "); writeAttributes(attributes); write("/>"); } private void input(String fieldName, Type type) throws IOException { input(fieldName, type, OptionalAttributes.attr()); } // private void beginLabel(String label) throws IOException { // beginLabel(label, attr()); // } // private void beginLabel(String label, OptionalAttributes attributes) throws IOException { // beginLabel(attributes); // write(label); // } private void beginLabel(OptionalAttributes attributes) throws IOException { write("<label"); writeAttributes(attributes); endTag(); } private void endLabel() throws IOException { write("</label>"); } private void beginForm(OptionalAttributes attrs) throws IOException { write("<form class=\"well\" "); writeAttributes(attrs); write(">"); } private void writeAttributes(OptionalAttributes attrs) throws IOException { Map<String, String> attributes = attrs.build(); for (Map.Entry<String, String> entry : attributes.entrySet()) { write(" "); write(entry.getKey()); write("="); quote(); write(entry.getValue()); quote(); } } private void quote() throws IOException { write("\""); } private void endForm() throws IOException { write("</form>"); } public void beginAnchor(OptionalAttributes attrs) throws IOException { write("<a "); writeAttributes(attrs); endTag(); } public void endAnchor() throws IOException { write("</a>"); } public void writeBr() throws IOException { write("<br />"); } private void writeAnchor(OptionalAttributes attrs, String value) throws IOException { beginAnchor(attrs); write(value); endAnchor(); } public static String capitalize(String name) { if (name != null && name.length() != 0) { char[] chars = name.toCharArray(); chars[0] = Character.toUpperCase(chars[0]); return new String(chars); } else { return name; } } private void writeHiddenHttpMethodField(RequestMethod httpMethod) throws IOException { switch (httpMethod) { case GET: case POST: break; default: input(methodParam, Type.HIDDEN, OptionalAttributes.attr("value", httpMethod.name())); } } private String getHtmlConformingHttpMethod(RequestMethod requestMethod) { String ret; switch (requestMethod) { case GET: case POST: ret = requestMethod.name(); break; default: ret = RequestMethod.POST.name(); } return ret; } /** * Renders input fields for bean properties of bean to add or update or patch. * * @param beanType * to render * @param actionDescriptor * which describes the method * @param actionInputParameter * which requires the bean * @param currentCallValue * sample call value * @throws IOException */ private void recurseBeanProperties(Class<?> beanType, ActionDescriptor actionDescriptor, ActionInputParameter actionInputParameter, Object currentCallValue, String parentParamName) throws IOException { // TODO support Option provider by other method args? final BeanInfo beanInfo = getBeanInfo(beanType); final PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors(); // TODO collection and map // TODO: do not add two inputs for setter and ctor // TODO almost duplicate of HtmlResourceMessageConverter.recursivelyCreateObject if (RequestMethod.POST == RequestMethod.valueOf(actionDescriptor.getHttpMethod())) { 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 String paramName = jsonProperty.value(); Class parameterType = parameters[paramIndex]; // TODO duplicate below for PropertyDescriptors and in appendForm if (DataType.isSingleValueType(parameterType)) { if (actionInputParameter.isIncluded(paramName)) { Object propertyValue = PropertyUtils.getPropertyOrFieldValue(currentCallValue, paramName); ActionInputParameter constructorParamInputParameter = new SpringActionInputParameter (new MethodParameter(constructor, paramIndex), propertyValue); final Object[] possibleValues = actionInputParameter.getPossibleValues( constructor, paramIndex, actionDescriptor); appendInputOrSelect(actionInputParameter, parentParamName + paramName, constructorParamInputParameter, possibleValues); } } else if (DataType.isArrayOrCollection(parameterType)) { Object[] callValues = actionInputParameter.getValues(); int items = callValues.length; for (int i = 0; i < items; i++) { Object value; if (i < callValues.length) { value = callValues[i]; } else { value = null; } recurseBeanProperties(actionInputParameter.getParameterType(), actionDescriptor, actionInputParameter, value, parentParamName); } } else { beginDiv(); write(paramName + ":"); Object propertyValue = PropertyUtils.getPropertyOrFieldValue(currentCallValue, paramName); recurseBeanProperties(parameterType, actionDescriptor, actionInputParameter, propertyValue, paramName + "."); endDiv(); } paramIndex++; // increase for each @JsonProperty } } } Assert.isTrue(parameters.length == paramIndex, "not all constructor arguments of @JsonCreator " + constructor.getName() + " are annotated with @JsonProperty"); } } catch (Exception e) { throw new RuntimeException("Failed to write input fields for constructor", e); } } else { // non-POST // TODO non-writable properties and public fields: make sure the inputs are part of a form // write input field for every setter for (PropertyDescriptor propertyDescriptor : propertyDescriptors) { final Method writeMethod = propertyDescriptor.getWriteMethod(); if (writeMethod == null) { continue; } final Class<?> propertyType = propertyDescriptor.getPropertyType(); String propertyName = propertyDescriptor.getName(); if (DataType.isSingleValueType(propertyType)) { final Property property = new Property(beanType, propertyDescriptor.getReadMethod(), propertyDescriptor.getWriteMethod(), propertyDescriptor.getName()); Object propertyValue = PropertyUtils.getPropertyOrFieldValue(currentCallValue, propertyName); MethodParameter methodParameter = new MethodParameter(propertyDescriptor.getWriteMethod(), 0); ActionInputParameter propertySetterInputParameter = new SpringActionInputParameter(methodParameter, propertyValue); final Object[] possibleValues = actionInputParameter.getPossibleValues(propertyDescriptor .getWriteMethod(), 0, actionDescriptor); appendInputOrSelect(actionInputParameter, propertyName, propertySetterInputParameter, possibleValues); } else if (actionInputParameter.isArrayOrCollection()) { Object[] callValues = actionInputParameter.getValues(); int items = callValues.length; for (int i = 0; i < items; i++) { Object value; if (i < callValues.length) { value = callValues[i]; } else { value = null; } recurseBeanProperties(actionInputParameter.getParameterType(), actionDescriptor, actionInputParameter, value, parentParamName); } } else { beginDiv(); write(propertyName + ":"); Object propertyValue = PropertyUtils.getPropertyValue(currentCallValue, propertyDescriptor); recurseBeanProperties(propertyType, actionDescriptor, actionInputParameter, propertyValue, parentParamName); endDiv(); } } } } /** * Appends simple input or select, depending on availability of possible values. * * @param parentInputParameter * the parent during bean recursion * @param paramName * of the child input parameter * @param childInputParameter * the current input to be rendered * @param possibleValues * suitable for childInputParameter * @throws IOException */ private void appendInputOrSelect(ActionInputParameter parentInputParameter, String paramName, ActionInputParameter childInputParameter, Object[] possibleValues) throws IOException { if (possibleValues.length > 0) { if (childInputParameter.isArrayOrCollection()) { // TODO multiple formatted callvalues appendSelectMulti(paramName, possibleValues, childInputParameter); } else { appendSelectOne(paramName, possibleValues, childInputParameter); } } else { appendInput(paramName, childInputParameter, childInputParameter.getValue(), parentInputParameter.isReadOnly(paramName)); } } private BeanInfo getBeanInfo(Class<?> beanType) { try { return Introspector.getBeanInfo(beanType); } catch (IntrospectionException e) { throw new RuntimeException(e); } } private void appendInput(String requestParamName, ActionInputParameter actionInputParameter, Object value, boolean readOnly) throws IOException { if (actionInputParameter.isRequestBody()) { // recurseBeanProperties does that throw new IllegalArgumentException("cannot append input field for requestBody"); } String fieldLabel = requestParamName; Type htmlInputFieldType = actionInputParameter.getHtmlInputFieldType(); Assert.notNull(htmlInputFieldType); String val = value == null ? "" : value.toString(); beginDiv(OptionalAttributes.attr("class", formGroupClass)); if (Type.HIDDEN.equals(htmlInputFieldType)) { input(requestParamName, htmlInputFieldType, OptionalAttributes.attr("value", val)); } else { String documentationUrl = documentationProvider.getDocumentationUrl(actionInputParameter, value); // TODO consider @Input-include/exclude/hidden here OptionalAttributes attrs = OptionalAttributes.attr("value", val); if (readOnly) { attrs.and(Input.READONLY, Input.READONLY); } writeLabelWithDoc(fieldLabel, requestParamName, documentationUrl); if (actionInputParameter.hasInputConstraints()) { for (Map.Entry<String, Object> inputConstraint : actionInputParameter.getInputConstraints() .entrySet()) { attrs.and(inputConstraint.getKey(), inputConstraint.getValue() .toString()); } } input(requestParamName, htmlInputFieldType, attrs); } endDiv(); } private void writeLabelWithDoc(String label, String fieldName, String documentationUrl) throws IOException { beginLabel(OptionalAttributes.attr("for", fieldName) .and("class", controlLabelClass)); if (documentationUrl == null) { write(label); } else { beginAnchor(XhtmlWriter.OptionalAttributes.attr("href", documentationUrl) .and("title", documentationUrl)); write(label); endAnchor(); } endLabel(); } private void appendSelectOne(String requestParamName, Object[] possibleValues, ActionInputParameter actionInputParameter) throws IOException { beginDiv(OptionalAttributes.attr("class", formGroupClass)); Object callValue = actionInputParameter.getValue(); String documentationUrl = documentationProvider.getDocumentationUrl(actionInputParameter, callValue); writeLabelWithDoc(requestParamName, requestParamName, documentationUrl); beginSelect(requestParamName, requestParamName, possibleValues.length, OptionalAttributes.attr("class", formControlClass)); for (Object possibleValue : possibleValues) { if (possibleValue.equals(callValue)) { option(possibleValue.toString(), attr("selected", "selected")); } else { option(possibleValue.toString()); } } endSelect(); endDiv(); } private void appendSelectMulti(String requestParamName, Object[] possibleValues, ActionInputParameter actionInputParameter) throws IOException { beginDiv(OptionalAttributes.attr("class", formGroupClass)); Object[] actualValues = actionInputParameter.getValues(); final Object aCallValue; if (actualValues.length > 0) { aCallValue = actualValues[0]; } else { aCallValue = null; } String documentationUrl = documentationProvider.getDocumentationUrl(actionInputParameter, aCallValue); writeLabelWithDoc(requestParamName, requestParamName, documentationUrl); beginSelect(requestParamName, requestParamName, possibleValues.length, OptionalAttributes.attr("multiple", "multiple") .and("class", formControlClass)); for (Object possibleValue : possibleValues) { if (ObjectUtils.containsElement(actualValues, possibleValue)) { option(possibleValue.toString(), attr("selected", "selected")); } else { option(possibleValue.toString()); } } endForm(); endDiv(); } private void option(String option) throws IOException { option(option, attr()); } private void option(String option, OptionalAttributes attr) throws IOException { // <option selected='selected'>%s</option> beginTag("option"); writeAttributes(attr); endTag(); write(option); write("</option>"); } private void beginTag(String tag) throws IOException { write("<"); write(tag); } private void endTag() throws IOException { write(">"); } private void beginSelect(String name, String id, int size) throws IOException { beginSelect(name, id, size, attr()); } private void beginSelect(String name, String id, int size, OptionalAttributes attrs) throws IOException { beginTag("select"); write(" name="); quote(name); write(" id="); quote(id); //write(" size="); //quote(Integer.toString(size)); writeAttributes(attrs); endTag(); } private void endSelect() throws IOException { write("</select>"); } private void quote(String s) throws IOException { quote(); write(s); quote(); } }