com.github.hateoas.forms.spring.xhtml.XhtmlResourceMessageConverter.java Source code

Java tutorial

Introduction

Here is the source code for com.github.hateoas.forms.spring.xhtml.XhtmlResourceMessageConverter.java

Source

/*
 * 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 com.github.hateoas.forms.spring.xhtml;

import javax.servlet.http.HttpServletRequest;
import java.beans.BeanInfo;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.io.*;
import java.lang.annotation.Annotation;
import java.lang.reflect.*;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.util.*;
import java.util.Map.Entry;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.hateoas.forms.PropertyUtils;
import com.github.hateoas.forms.affordance.DataType;
import com.github.hateoas.forms.spring.DefaultDocumentationProvider;
import com.github.hateoas.forms.spring.DocumentationProvider;

import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.hateoas.Resources;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.AbstractHttpMessageConverter;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.util.*;

/**
 * Message converter which represents a restful API as xhtml which can be used by the browser or a rest client.
 * Converts java beans and spring-hateoas Resources to xhtml and maps the body of x-www-form-urlencoded
 * requests to RequestBody method parameters.
 * The media-type xhtml does not officially support methods other than GET or POST, therefore we must "tunnel"
 * other methods when this converter is used with the browser.
 * Spring's {@link org.springframework.web.filter.HiddenHttpMethodFilter} allows to do that with relative ease.
 *
 * @author Dietrich Schulten
 */
public class XhtmlResourceMessageConverter extends AbstractHttpMessageConverter<Object> {

    private Charset charset = Charset.forName("UTF-8");
    private String methodParam = "_method";
    private List<String> stylesheets = Collections.emptyList();

    private DocumentationProvider documentationProvider = new DefaultDocumentationProvider();

    public XhtmlResourceMessageConverter() {
        this.setSupportedMediaTypes(Arrays.asList(MediaType.TEXT_HTML, MediaType.APPLICATION_FORM_URLENCODED));
    }

    @Override
    protected boolean supports(Class<?> clazz) {
        return true;
    }

    public Object read(java.lang.reflect.Type type, Class<?> contextClass, HttpInputMessage inputMessage)
            throws IOException, HttpMessageNotReadableException {

        final Class clazz;
        if (type instanceof Class) {
            clazz = (Class) type;
        } else if (type instanceof ParameterizedType) {
            ParameterizedType parameterizedType = (ParameterizedType) type;
            Type rawType = parameterizedType.getRawType();
            if (rawType instanceof Class) {
                clazz = (Class) rawType;
            } else {
                throw new IllegalArgumentException("unexpected raw type " + rawType);
            }
        } else {
            throw new IllegalArgumentException("unexpected type " + type);
        }
        return readInternal(clazz, inputMessage);
    }

    @Override
    protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage)
            throws IOException, HttpMessageNotReadableException {

        InputStream is;
        if (inputMessage instanceof ServletServerHttpRequest) {
            // this is necessary to support HiddenHttpMethodFilter
            // thanks to https://www.w3.org/html/wg/tracker/issues/195
            // but see http://dev.w3.org/html5/decision-policy/html5-2014-plan.html#issues
            // and http://cameronjones.github.io/form-http-extensions/index.html
            // and http://www.w3.org/TR/form-http-extensions/
            // TODO recognize this more safely or make the filter mandatory
            MediaType contentType = inputMessage.getHeaders().getContentType();
            Charset charset = contentType.getCharSet() != null ? contentType.getCharSet() : this.charset;
            ServletServerHttpRequest servletServerHttpRequest = (ServletServerHttpRequest) inputMessage;
            HttpServletRequest servletRequest = servletServerHttpRequest.getServletRequest();
            is = getBodyFromServletRequestParameters(servletRequest, charset.displayName(Locale.US));
        } else {
            is = inputMessage.getBody();
        }
        return readRequestBody(clazz, is, charset);
    }

    /**
     * From {@link ServletServerHttpRequest}:
     * Use {@link javax.servlet.ServletRequest#getParameterMap()} to reconstruct the
     * body of a form 'POST' providing a predictable outcome as opposed to reading
     * from the body, which can fail if any other code has used ServletRequest
     * to access a parameter thus causing the input stream to be "consumed".
     */
    private InputStream getBodyFromServletRequestParameters(HttpServletRequest request, String charset)
            throws IOException {

        ByteArrayOutputStream bos = new ByteArrayOutputStream(1024);
        Writer writer = new OutputStreamWriter(bos, charset);
        @SuppressWarnings("unchecked")
        Map<String, String[]> form = request.getParameterMap();
        for (Iterator<String> nameIterator = form.keySet().iterator(); nameIterator.hasNext();) {
            String name = nameIterator.next();
            List<String> values = Arrays.asList(form.get(name));
            for (Iterator<String> valueIterator = values.iterator(); valueIterator.hasNext();) {
                String value = valueIterator.next();
                writer.write(URLEncoder.encode(name, charset));
                if (value != null) {
                    writer.write('=');
                    writer.write(URLEncoder.encode(value, charset));
                    if (valueIterator.hasNext()) {
                        writer.write('&');
                    }
                }
            }
            if (nameIterator.hasNext()) {
                writer.append('&');
            }
        }
        writer.flush();

        return new ByteArrayInputStream(bos.toByteArray());
    }

    private Object readRequestBody(Class<?> clazz, InputStream inputStream, Charset charset) throws IOException {

        String body = StreamUtils.copyToString(inputStream, charset);

        String[] pairs = StringUtils.tokenizeToStringArray(body, "&");

        MultiValueMap<String, String> formValues = new LinkedMultiValueMap<String, String>(pairs.length);

        for (String pair : pairs) {
            int idx = pair.indexOf('=');
            if (idx == -1) {
                formValues.add(URLDecoder.decode(pair, charset.name()), null);
            } else {
                String name = URLDecoder.decode(pair.substring(0, idx), charset.name());
                String value = URLDecoder.decode(pair.substring(idx + 1), charset.name());
                formValues.add(name, value);
            }
        }

        return recursivelyCreateObject(clazz, formValues, "");
    }

    Object recursivelyCreateObject(Class<?> clazz, MultiValueMap<String, String> formValues,
            String parentParamName) {

        if (Map.class.isAssignableFrom(clazz)) {
            throw new IllegalArgumentException("Map not supported");
        } else if (Collection.class.isAssignableFrom(clazz)) {
            throw new IllegalArgumentException("Collection not supported");
        } else {
            try {
                Constructor[] constructors = clazz.getConstructors();
                Constructor constructor = PropertyUtils.findDefaultCtor(constructors);
                if (constructor == null) {
                    constructor = PropertyUtils.findJsonCreator(constructors, JsonCreator.class);
                }
                Assert.notNull(constructor, "no default constructor or JsonCreator found");
                int parameterCount = constructor.getParameterTypes().length;
                Object[] args = new Object[parameterCount];
                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;
                                String paramName = jsonProperty.value();
                                List<String> formValue = formValues.get(parentParamName + paramName);
                                Class<?> parameterType = parameters[paramIndex];
                                if (DataType.isSingleValueType(parameterType)) {
                                    if (formValue != null) {
                                        if (formValue.size() == 1) {
                                            args[paramIndex++] = DataType.asType(parameterType, formValue.get(0));
                                        } else {
                                            //                                        // TODO create proper collection type
                                            throw new IllegalArgumentException("variable list not supported");
                                            //                                        List<Object> listValue = new ArrayList<Object>();
                                            //                                        for (String item : formValue) {
                                            //                                            listValue.add(DataType.asType(parameterType, formValue.get(0)));
                                            //                                        }
                                            //                                        args[paramIndex++] = listValue;
                                        }
                                    } else {
                                        args[paramIndex++] = null;
                                    }
                                } else {
                                    args[paramIndex++] = recursivelyCreateObject(parameterType, formValues,
                                            parentParamName + paramName + ".");
                                }
                            }
                        }
                    }
                    Assert.isTrue(args.length == paramIndex,
                            "not all constructor arguments of @JsonCreator are " + "annotated with @JsonProperty");
                }
                Object ret = constructor.newInstance(args);
                BeanInfo beanInfo = Introspector.getBeanInfo(clazz);
                PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors();
                for (PropertyDescriptor propertyDescriptor : propertyDescriptors) {
                    Method writeMethod = propertyDescriptor.getWriteMethod();
                    String name = propertyDescriptor.getName();
                    List<String> strings = formValues.get(name);
                    if (writeMethod != null && strings != null && strings.size() == 1) {
                        writeMethod.invoke(ret,
                                DataType.asType(propertyDescriptor.getPropertyType(), strings.get(0))); // TODO lists, consume values from ctor
                    }
                }
                return ret;
            } catch (Exception e) {
                throw new RuntimeException("Failed to instantiate bean " + clazz.getName(), e);
            }
        }
    }

    @Override
    protected void writeInternal(Object t, HttpOutputMessage outputMessage)
            throws IOException, HttpMessageNotWritableException {

        XhtmlWriter xhtmlWriter = new XhtmlWriter(new OutputStreamWriter(outputMessage.getBody(), "UTF-8"));
        xhtmlWriter.setMethodParam(methodParam);
        xhtmlWriter.setStylesheets(stylesheets);
        xhtmlWriter.setDocumentationProvider(documentationProvider);

        xhtmlWriter.beginHtml("Form");
        writeNewResource(xhtmlWriter, t);
        xhtmlWriter.endHtml();
        xhtmlWriter.flush();
    }

    static final Set<String> FILTER_RESOURCE_SUPPORT = new HashSet<String>(Arrays.asList("class", "links", "id"));

    private void writeNewResource(XhtmlWriter writer, Object object) throws IOException {
        writer.beginUnorderedList();
        writeResource(writer, object);
        writer.endUnorderedList();
    }

    /**
     * Recursively converts object to xhtml data.
     *
     * @param object to convert
     * @param writer to write to
     */
    private void writeResource(XhtmlWriter writer, Object object) {
        if (object == null) {
            return;
        }
        try {
            if (object instanceof Resource) {
                Resource<?> resource = (Resource<?>) object;
                writer.beginListItem();

                writeResource(writer, resource.getContent());
                writer.writeLinks(resource.getLinks());

                writer.endListItem();
            } else if (object instanceof Resources) {
                Resources<?> resources = (Resources<?>) object;
                // TODO set name using EVO see HypermediaSupportBeanDefinitionRegistrar

                writer.beginListItem();

                writer.beginUnorderedList();
                Collection<?> content = resources.getContent();
                writeResource(writer, content);
                writer.endUnorderedList();

                writer.writeLinks(resources.getLinks());

                writer.endListItem();
            } else if (object instanceof ResourceSupport) {
                ResourceSupport resource = (ResourceSupport) object;
                writer.beginListItem();

                writeObject(writer, resource);
                writer.writeLinks(resource.getLinks());

                writer.endListItem();
            } else if (object instanceof Collection) {
                Collection<?> collection = (Collection<?>) object;
                for (Object item : collection) {
                    writeResource(writer, item);
                }
            } else { // TODO: write li for simple objects in Resources Collection
                writeObject(writer, object);
            }
        } catch (Exception ex) {
            throw new RuntimeException("failed to transform object " + object, ex);
        }
    }

    private void beginListGroupWithItem(XhtmlWriter writer) throws IOException {
        writer.beginUnorderedList();
        writer.beginListItem();
    }

    private void endListGroupWithItem(XhtmlWriter writer) throws IOException {
        writer.endListItem();
        writer.endUnorderedList();
    }

    private void writeObject(XhtmlWriter writer, Object object)
            throws IOException, IllegalAccessException, InvocationTargetException {
        if (!DataType.isSingleValueType(object.getClass())) {
            writer.beginDl();
        }
        if (object instanceof Map) {
            Map<?, ?> map = (Map<?, ?>) object;
            for (Entry<?, ?> entry : map.entrySet()) {
                String name = entry.getKey().toString();
                Object content = entry.getValue();
                String docUrl = documentationProvider.getDocumentationUrl(name, content);
                writeObjectAttributeRecursively(writer, name, content, docUrl);
            }
        } else if (object instanceof Enum) {
            String name = ((Enum) object).name();
            String docUrl = documentationProvider.getDocumentationUrl(name, object);
            writeDdForScalarValue(writer, object);
        } else if (object instanceof Currency) {
            // TODO configurable classes which should be rendered with toString
            // or use JsonSerializer or DataType?
            String name = object.toString();
            String docUrl = documentationProvider.getDocumentationUrl(name, object);
            writeDdForScalarValue(writer, object);
        } else {
            Class<?> aClass = object.getClass();
            Map<String, PropertyDescriptor> propertyDescriptors = PropertyUtils.getPropertyDescriptors(object);
            // getFields retrieves public only
            Field[] fields = aClass.getFields();
            for (Field field : fields) {
                String name = field.getName();
                if (!propertyDescriptors.containsKey(name)) {
                    Object content = field.get(object);
                    String docUrl = documentationProvider.getDocumentationUrl(field, content);
                    //<a href="http://schema.org/review">http://schema.org/performer</a>
                    writeObjectAttributeRecursively(writer, name, content, docUrl);
                }
            }
            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);
                    writeObjectAttributeRecursively(writer, name, content, docUrl);
                }
            }
        }
        if (!DataType.isSingleValueType(object.getClass())) {
            writer.endDl();
        }
    }

    private void writeObjectAttributeRecursively(XhtmlWriter writer, String name, Object content,
            String documentationUrl) throws IOException {
        Object value = getContentAsScalarValue(content);
        if (!contentIsEmpty(content)) {
            writeDtWithDoc(writer, name, documentationUrl);
        }
        if (value != null) {
            if (value != NULL_VALUE) {
                writeDdForScalarValue(writer, value);
            }
        } else if (DataType.isSingleValueType(content.getClass())) {
            writeDdForScalarValue(writer, content.toString());
        } else {
            writer.beginDd();
            writeNewResource(writer, content);
            writer.endDd();
        }
    }

    private boolean contentIsEmpty(Object content) {
        final boolean ret;
        if (content != null) {
            if (content instanceof Collection) {
                ret = ((Collection) content).isEmpty();
            } else if (content instanceof Map) {
                ret = ((Map) content).isEmpty();
            } else if (content instanceof String) {
                ret = ((String) content).isEmpty();
            } else {
                ret = false;
            }
        } else {
            ret = true;
        }
        return ret;
    }

    private void writeDtWithDoc(XhtmlWriter writer, String name, String documentationUrl) throws IOException {
        if (documentationUrl == null) {
            writer.beginDt();
            writer.write(name);
            writer.endDt();
        } else {
            writer.beginDt();
            writer.beginAnchor(
                    XhtmlWriter.OptionalAttributes.attr("href", documentationUrl).and("title", documentationUrl));
            writer.write(name);
            writer.endAnchor();
            writer.endDt();
        }
    }

    private void writeDdForScalarValue(XhtmlWriter writer, Object value) throws IOException {
        writer.beginDd();
        writer.write(value.toString());
        writer.endDd();
    }

    /**
     * Sets method param name for HTML PUT/DELETE/PATCH workaround.
     *
     * @param methodParam to use
     * @see org.springframework.web.filter.HiddenHttpMethodFilter
     */
    public void setMethodParam(String methodParam) {
        this.methodParam = methodParam;
    }

    /**
     * Sets css stylesheets to apply to the form.
     *
     * @param stylesheets urls of css stylesheets to include,
     * e.g. &quot;https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css&quot;
     */
    public void setStylesheets(List<String> stylesheets) {
        Assert.notNull(stylesheets);
        this.stylesheets = stylesheets;
    }

    public void setDocumentationProvider(DocumentationProvider documentationProvider) {
        this.documentationProvider = documentationProvider;
    }

    static class NullValue {

    }

    public static final NullValue NULL_VALUE = new NullValue();

    private static Object getContentAsScalarValue(Object content) {
        Object value = null;

        if (content == null) {
            value = NULL_VALUE;
        } else if (content instanceof String || content instanceof Number || content.equals(false)
                || content.equals(true)) {
            value = content;
        }
        return value;
    }
}