001package io.jstach.opt.spring.webflux;
002
003import java.nio.charset.Charset;
004import java.nio.charset.StandardCharsets;
005import java.util.List;
006import java.util.Map;
007
008import org.eclipse.jdt.annotation.Nullable;
009import org.springframework.core.ResolvableType;
010import org.springframework.core.codec.AbstractSingleValueEncoder;
011import org.springframework.core.codec.Hints;
012import org.springframework.core.io.buffer.DataBuffer;
013import org.springframework.core.io.buffer.DataBufferFactory;
014import org.springframework.http.MediaType;
015import org.springframework.util.MimeType;
016import org.springframework.web.server.NotAcceptableStatusException;
017
018import io.jstach.jstachio.JStachio;
019import io.jstach.jstachio.Template;
020import reactor.core.publisher.Flux;
021
022/**
023 * Encodes a JStachio model into a bytes to be used as output from a webflux reactive
024 * controller.
025 *
026 * @author agentgt
027 * @author dsyer
028 */
029@SuppressWarnings("exports")
030public class JStachioEncoder extends AbstractSingleValueEncoder<Object> {
031
032        private final JStachio jstachio;
033
034        private final int allocateBufferSize;
035
036        private final MediaType mediaType;
037
038        /**
039         * TODO make public on minor release
040         */
041        static final int DEFAULT_BUFFER_SIZE = 4 * 1024;
042
043        /**
044         * The default media type is "<code>text/html; charset=UTF-8</code>".
045         */
046        static final MediaType DEFAULT_MEDIA_TYPE = new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8);
047
048        /**
049         * Create the encoder from a JStachio
050         * @param jstachio not <code>null</code>.
051         */
052        public JStachioEncoder(JStachio jstachio) {
053                this(jstachio, DEFAULT_BUFFER_SIZE);
054        }
055
056        /**
057         * Create the encoder from a JStachio
058         * @param jstachio not <code>null</code>.
059         * @param allocateBufferSize how much to initially allocate from the buffer factory
060         */
061        public JStachioEncoder(JStachio jstachio, int allocateBufferSize) {
062                this(jstachio, allocateBufferSize, DEFAULT_MEDIA_TYPE);
063        }
064
065        /*
066         * TODO possibly make public on minor release
067         */
068        JStachioEncoder(JStachio jstachio, int allocateBufferSize, MediaType mediaType) {
069                super(mediaType);
070                this.jstachio = jstachio;
071                this.allocateBufferSize = allocateBufferSize;
072                this.mediaType = mediaType;
073        }
074
075        @Override
076        public boolean canEncode(ResolvableType elementType, @Nullable MimeType mimeType) {
077                Class<?> clazz = elementType.toClass();
078                /*
079                 * WE MUST BLOCK all other encoders regardless of mimetype if it is a jstache
080                 * model otherwise we could serialize a model that has sensitive data as JSON.
081                 */
082                return clazz != Object.class && !CharSequence.class.isAssignableFrom(clazz) && jstachio.supportsType(clazz);
083        }
084
085        @Override
086        protected Flux<DataBuffer> encode(Object event, DataBufferFactory bufferFactory, ResolvableType type,
087                        @Nullable MimeType mimeType, @Nullable Map<String, Object> hints) {
088                return Flux.just(encodeValue(event, bufferFactory, type, mimeType, hints));
089        }
090
091        @Override
092        public DataBuffer encodeValue(Object event, DataBufferFactory bufferFactory, ResolvableType valueType,
093                        @Nullable MimeType mimeType, @Nullable Map<String, Object> hints) {
094                if (logger.isDebugEnabled() && !Hints.isLoggingSuppressed(hints)) {
095                        String logPrefix = Hints.getLogPrefix(hints);
096                        logger.debug(logPrefix + "Writing [" + event + "]");
097                }
098
099                /*
100                 * We check the media type to see if it matches otherwise Spring will default to
101                 * application/json and will send Content-Type application/json back but return
102                 * our almost always NOT JSON body (jstachio should obviously not be used for
103                 * generating json).
104                 *
105                 * See #176
106                 */
107                if (mimeType != null && !mimeType.isCompatibleWith(this.mediaType)) {
108                        /*
109                         * Returning 406 is not ideal for HTML responses
110                         *
111                         * https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/406
112                         *
113                         * > In practice, this error is very rarely used. Instead of responding using
114                         * this > error code, which would be cryptic for the end user and difficult to
115                         * fix, > servers ignore the relevant header and serve an actual page to the
116                         * user. It > is assumed that even if the user won't be completely happy, they
117                         * will prefer > this to an error code.
118                         */
119                        throw new NotAcceptableStatusException(List.of(this.mediaType));
120                }
121
122                return encode(jstachio, event, bufferFactory, allocateBufferSize, this.mediaType.getCharset());
123
124        }
125
126        static DataBuffer encode( //
127                        JStachio jstachio, //
128                        Object event, //
129                        DataBufferFactory bufferFactory, //
130                        int bufferSize, //
131                        @Nullable Charset charset) {
132                Template<Object> template;
133                try {
134                        template = jstachio.findTemplate(event);
135                }
136                catch (Exception e) {
137                        throw new RuntimeException(e);
138                }
139                if (charset == null) {
140                        charset = template.templateCharset();
141                }
142
143                DataBufferOutput output = new DataBufferOutput(bufferFactory.allocateBuffer(bufferSize), charset);
144
145                return template.write(event, output).getBuffer();
146        }
147
148}