001package io.jstach.opt.spring.web;
002
003import java.io.IOException;
004import java.io.OutputStream;
005import java.nio.charset.Charset;
006import java.nio.charset.StandardCharsets;
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.CloseableEncodedOutput;
018import io.jstach.jstachio.output.ByteBufferEncodedOutput;
019import io.jstach.jstachio.output.ChunkEncodedOutput;
020import io.jstach.jstachio.output.LimitEncodedOutput;
021import io.jstach.jstachio.output.ThresholdEncodedOutput;
022
023/**
024 * Type-safe way to use JStachio in Spring Web.
025 * <p>
026 * For this to work the controllers need to return JStache models and have the controller
027 * method return annotated with {@link ResponseBody}.
028 * <p>
029 * <strong>Example:</strong> <pre><code class="language-java">
030 * &#64;JStache
031 * public record HelloModel(String message){}
032 *
033 * &#64;GetMapping(value = "/")
034 * &#64;ResponseBody
035 * public HelloModel hello() {
036 *     return new HelloModel("Spring Boot is now JStachioed!");
037 * }
038 * </code> </pre> Because JStachio by default pre-encodes the static text parts of the
039 * template the output strategy handles the buffering instead of the framework (usually
040 * servlet) to improve performance and to reliable set <code>Content-Length</code>. This
041 * can be changed by overriding {@link #createOutput(HttpOutputMessage)}.
042 *
043 * @author agentgt
044 *
045 */
046public class JStachioHttpMessageConverter extends AbstractHttpMessageConverter<Object> {
047
048        /**
049         * The default media type is "<code>text/html; charset=UTF-8</code>".
050         */
051        @SuppressWarnings("exports")
052        public static final MediaType DEFAULT_MEDIA_TYPE = new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8);
053
054        /**
055         * The default buffer limit before bailing on trying to set
056         * <code>Content-Length</code>. The default is "{@value #DEFAULT_BUFFER_LIMIT}".
057         */
058        public static final int DEFAULT_BUFFER_LIMIT = 1024 * 64;
059
060        private final JStachio jstachio;
061
062        private final MediaType mediaType;
063
064        /**
065         * The maximum amount of bytes to buffer.
066         */
067        protected final int bufferLimit;
068
069        /**
070         * Create http converter from jstachio
071         * @param jstachio an instance usually created by spring
072         */
073        public JStachioHttpMessageConverter(JStachio jstachio) {
074                this(jstachio, DEFAULT_MEDIA_TYPE, DEFAULT_BUFFER_LIMIT);
075        }
076
077        /**
078         * Creates a message converter with media type and buffer limit.
079         * @param jstachio an instance usually created by spring
080         * @param mediaType used to set ContentType
081         * @param bufferLimit buffer limit before bailing on trying to set
082         * <code>Content-Length</code>.
083         */
084        protected JStachioHttpMessageConverter(JStachio jstachio, MediaType mediaType, int bufferLimit) {
085                super(resolveCharset(mediaType), mediaType, MediaType.ALL);
086                this.jstachio = jstachio;
087                this.mediaType = mediaType;
088                this.bufferLimit = bufferLimit;
089        }
090
091        private static Charset resolveCharset(MediaType mediaType) {
092                var charset = mediaType.getCharset();
093                if (charset == null) {
094                        return StandardCharsets.UTF_8;
095                }
096                return charset;
097        }
098
099        @Override
100        protected boolean supports(Class<?> clazz) {
101                return jstachio.supportsType(clazz);
102        }
103
104        @Override
105        public boolean canRead(Class<?> clazz, @SuppressWarnings("exports") MediaType mediaType) {
106                return jstachio.supportsType(clazz);
107        }
108
109        @Override
110        protected Object readInternal(Class<? extends Object> clazz, HttpInputMessage inputMessage)
111                        throws IOException, HttpMessageNotReadableException {
112                throw new HttpMessageNotReadableException("Input not supported by JStachio", inputMessage);
113
114        }
115
116        @Override
117        protected void writeInternal(Object t, HttpOutputMessage outputMessage)
118                        throws IOException, HttpMessageNotWritableException {
119
120                /*
121                 * We have to override the content type here because if we do not Spring appears
122                 * to default to application/json if the Accept does not include HTML.
123                 */
124                var headers = outputMessage.getHeaders();
125
126                /*
127                 * If we just write directly to the body without resolving content length first it
128                 * might not get set and we MIGHT get Transfer-Encoding: chunked which is almost
129                 * never desired for HTML.
130                 */
131                headers.setContentType(mediaType);
132                try (CloseableEncodedOutput<IOException> output = createOutput(outputMessage)) {
133                        jstachio.write(t, output);
134                }
135        }
136
137        /**
138         * Create the buffered output to use when executing JStachio.
139         * @param message response.
140         * @return the output ready for writing to.
141         * @see ByteBufferEncodedOutput
142         * @see ChunkEncodedOutput
143         * @see LimitEncodedOutput
144         */
145        protected CloseableEncodedOutput<IOException> createOutput(HttpOutputMessage message) {
146                return new HttpOutputMessageEncodedOutput(getDefaultCharset(), message, bufferLimit);
147        }
148
149}
150
151class HttpOutputMessageEncodedOutput extends ThresholdEncodedOutput.OutputStreamThresholdEncodedOutput {
152
153        private final HttpOutputMessage response;
154
155        public HttpOutputMessageEncodedOutput(Charset charset, HttpOutputMessage response, int limit) {
156                super(charset, limit);
157                this.response = response;
158        }
159
160        @Override
161        protected OutputStream createConsumer(int size) throws IOException {
162                if (size > -1) {
163                        response.getHeaders().setContentLength(size);
164                }
165                return response.getBody();
166        }
167
168}