001package io.jstach.opt.spring.web;
002
003import java.io.IOException;
004import java.nio.charset.Charset;
005import java.nio.charset.StandardCharsets;
006import java.util.Objects;
007
008import org.springframework.http.HttpInputMessage;
009import org.springframework.http.HttpOutputMessage;
010import org.springframework.http.MediaType;
011import org.springframework.http.converter.AbstractHttpMessageConverter;
012import org.springframework.http.converter.HttpMessageNotReadableException;
013import org.springframework.http.converter.HttpMessageNotWritableException;
014import org.springframework.web.bind.annotation.ResponseBody;
015
016import io.jstach.jstachio.JStachio;
017import io.jstach.jstachio.Output;
018import io.jstach.jstachio.output.ByteBufferedOutputStream;
019
020/**
021 * Typesafe way to use JStachio in Spring Web.
022 * <p>
023 * For this to work the controllers need to return JStache models and have the controller
024 * method return annotated with {@link ResponseBody}.
025 * <p>
026 * <strong>Example:</strong> <pre><code class="language-java">
027 * &#64;JStache
028 * public record HelloModel(String message){}
029 *
030 * &#64;GetMapping(value = "/")
031 * &#64;ResponseBody
032 * public HelloModel hello() {
033 *     return new HelloModel("Spring Boot is now JStachioed!");
034 * }
035 * </code> </pre>
036 *
037 * @author agentgt
038 *
039 */
040public class JStachioHttpMessageConverter extends AbstractHttpMessageConverter<Object> {
041
042        /**
043         * The default media type is "<code>text/html; charset=UTF-8</code>".
044         */
045        static final MediaType DEFAULT_MEDIA_TYPE = new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8);
046
047        private final JStachio jstachio;
048
049        /**
050         * Create http converter from jstachio
051         * @param jstachio an instance usually created by spring
052         */
053        public JStachioHttpMessageConverter(JStachio jstachio) {
054                super(StandardCharsets.UTF_8, MediaType.TEXT_HTML, MediaType.ALL);
055                this.jstachio = jstachio;
056        }
057
058        @Override
059        protected boolean supports(Class<?> clazz) {
060                return jstachio.supportsType(clazz);
061        }
062
063        @Override
064        public boolean canRead(Class<?> clazz, @SuppressWarnings("exports") MediaType mediaType) {
065                return jstachio.supportsType(clazz);
066        }
067
068        @Override
069        protected Object readInternal(Class<? extends Object> clazz, HttpInputMessage inputMessage)
070                        throws IOException, HttpMessageNotReadableException {
071                throw new HttpMessageNotReadableException("Input not supported by JStachio", inputMessage);
072
073        }
074
075        @Override
076        protected void writeInternal(Object t, HttpOutputMessage outputMessage)
077                        throws IOException, HttpMessageNotWritableException {
078                /*
079                 * If we just write directly to the body we will get Transfer-Encoding: chunked
080                 * which is almost never desired for HTML.
081                 *
082                 * TODO we should explore making this configurable or other options as this
083                 * requires copying all the data.
084                 */
085                try (ByteBufferedOutputStream buffer = new ByteBufferedOutputStream()) {
086                        // The try - with is not necessary but keeps linters happy
087                        jstachio.write(t, Output.of(buffer, charset()));
088                        int size = buffer.size();
089                        var headers = outputMessage.getHeaders();
090                        headers.setContentLength(size);
091                        /*
092                         * We have to override the content type here because if we do not Spring
093                         * appears to default to application/json if the Accept does not include HTML.
094                         */
095                        headers.setContentType(DEFAULT_MEDIA_TYPE);
096                        /*
097                         * buffer.toByteArray copies which we do not want so we use toBuffer which
098                         * does not
099                         */
100                        var bytes = buffer.toBuffer().array();
101                        var body = outputMessage.getBody();
102                        body.write(bytes, 0, size);
103                }
104        }
105
106        protected Charset charset() {
107                return Objects.requireNonNull(getDefaultCharset());
108        }
109
110}