All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.httprpc.kilo.io.TemplateEncoder Maven / Gradle / Ivy

There is a newer version: 4.9
Show newest version
/*
 * 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 org.httprpc.kilo.io;

import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.StringWriter;
import java.io.Writer;
import java.net.URL;
import java.text.NumberFormat;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.time.temporal.TemporalAccessor;
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.Deque;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.TimeZone;

import static org.httprpc.kilo.util.Collections.entry;
import static org.httprpc.kilo.util.Collections.listOf;
import static org.httprpc.kilo.util.Collections.mapOf;

/**
 * Encodes an object hierarchy using a template document.
 */
public class TemplateEncoder extends Encoder {
    /**
     * Represents a modifier.
     */
    public interface Modifier {
        /**
         * Applies the modifier.
         *
         * @param value
         * The value to which the modifier is being be applied.
         *
         * @param argument
         * The modifier argument, or {@code null} if no argument was provided.
         *
         * @param locale
         * The locale for which the modifier is being applied.
         *
         * @param timeZone
         * The time zone for which the modifier is being applied.
         *
         * @return
         * The modified value.
         */
        Object apply(Object value, String argument, Locale locale, TimeZone timeZone);
    }

    /**
     * Content type options.
     */
    public enum ContentType {
        /**
         * Markup content type.
         */
        MARKUP(new MarkupModifier()),

        /**
         * JSON content type.
         */
        JSON(new JSONModifier()),

        /**
         * CSV content type.
         */
        CSV(new CSVModifier()),

        /**
         * Unspecified content type.
         */
        UNSPECIFIED(null);

        private final Modifier modifier;

        ContentType(Modifier modifier) {
            this.modifier = modifier;
        }
    }

    // Markup modifier
    private static class MarkupModifier implements Modifier {
        @Override
        public Object apply(Object value, String argument, Locale locale, TimeZone timeZone) {
            if (value instanceof CharSequence text) {
                var stringBuilder = new StringBuilder();

                for (int i = 0, n = text.length(); i < n; i++) {
                    var c = text.charAt(i);

                    if (c == '<') {
                        stringBuilder.append("<");
                    } else if (c == '>') {
                        stringBuilder.append(">");
                    } else if (c == '&') {
                        stringBuilder.append("&");
                    } else if (c == '"') {
                        stringBuilder.append(""");
                    } else {
                        stringBuilder.append(c);
                    }
                }

                return stringBuilder.toString();
            } else {
                return value;
            }
        }
    }

    // JSON modifier
    private static class JSONModifier implements Modifier {
        JSONEncoder jsonEncoder = new JSONEncoder(true);

        @Override
        public Object apply(Object value, String argument, Locale locale, TimeZone timeZone) {
            var stringWriter = new StringWriter();

            try {
                jsonEncoder.encode(value, stringWriter);
            } catch (IOException exception) {
                throw new RuntimeException(exception);
            }

            return stringWriter.toString();
        }
    }

    // CSV modifier
    private static class CSVModifier implements Modifier {
        CSVEncoder csvEncoder = new CSVEncoder(listOf());

        @Override
        public Object apply(Object value, String argument, Locale locale, TimeZone timeZone) {
            var stringWriter = new StringWriter();

            try {
                csvEncoder.encode(value, stringWriter);
            } catch (IOException exception) {
                throw new RuntimeException(exception);
            }

            return stringWriter.toString();
        }
    }

    // Format modifier
    private static class FormatModifier implements Modifier {
        static final String CURRENCY = "currency";
        static final String PERCENT = "percent";

        static final String SHORT_DATE = "shortDate";
        static final String MEDIUM_DATE = "mediumDate";
        static final String LONG_DATE = "longDate";
        static final String FULL_DATE = "fullDate";
        static final String ISO_DATE = "isoDate";

        static final String SHORT_TIME = "shortTime";
        static final String MEDIUM_TIME = "mediumTime";
        static final String LONG_TIME = "longTime";
        static final String FULL_TIME = "fullTime";
        static final String ISO_TIME = "isoTime";

        static final String SHORT_DATE_TIME = "shortDateTime";
        static final String MEDIUM_DATE_TIME = "mediumDateTime";
        static final String LONG_DATE_TIME = "longDateTime";
        static final String FULL_DATE_TIME = "fullDateTime";
        static final String ISO_DATE_TIME = "isoDateTime";

        enum DateTimeType {
            DATE,
            TIME,
            DATE_TIME
        }

        @Override
        public Object apply(Object value, String argument, Locale locale, TimeZone timeZone) {
            if (argument == null) {
                return value;
            }

            return switch (argument) {
                case CURRENCY -> NumberFormat.getCurrencyInstance(locale).format(value);
                case PERCENT -> NumberFormat.getPercentInstance(locale).format(value);
                case SHORT_DATE -> format(value, DateTimeType.DATE, FormatStyle.SHORT, locale, timeZone);
                case MEDIUM_DATE -> format(value, DateTimeType.DATE, FormatStyle.MEDIUM, locale, timeZone);
                case LONG_DATE -> format(value, DateTimeType.DATE, FormatStyle.LONG, locale, timeZone);
                case FULL_DATE -> format(value, DateTimeType.DATE, FormatStyle.FULL, locale, timeZone);
                case ISO_DATE -> format(value, DateTimeType.DATE, null, null, timeZone);
                case SHORT_TIME -> format(value, DateTimeType.TIME, FormatStyle.SHORT, locale, timeZone);
                case MEDIUM_TIME -> format(value, DateTimeType.TIME, FormatStyle.MEDIUM, locale, timeZone);
                case LONG_TIME -> format(value, DateTimeType.TIME, FormatStyle.LONG, locale, timeZone);
                case FULL_TIME -> format(value, DateTimeType.TIME, FormatStyle.FULL, locale, timeZone);
                case ISO_TIME -> format(value, DateTimeType.TIME, null, null, timeZone);
                case SHORT_DATE_TIME -> format(value, DateTimeType.DATE_TIME, FormatStyle.SHORT, locale, timeZone);
                case MEDIUM_DATE_TIME -> format(value, DateTimeType.DATE_TIME, FormatStyle.MEDIUM, locale, timeZone);
                case LONG_DATE_TIME -> format(value, DateTimeType.DATE_TIME, FormatStyle.LONG, locale, timeZone);
                case FULL_DATE_TIME -> format(value, DateTimeType.DATE_TIME, FormatStyle.FULL, locale, timeZone);
                case ISO_DATE_TIME -> format(value, DateTimeType.DATE_TIME, null, null, timeZone);
                default -> String.format(locale, argument, value);
            };
        }

        static String format(Object value, DateTimeType dateTimeType, FormatStyle formatStyle, Locale locale, TimeZone timeZone) {
            if (value instanceof Number number) {
                value = new Date(number.longValue());
            }

            if (value instanceof Date date) {
                value = date.toInstant();
            }

            var zoneId = timeZone.toZoneId();

            TemporalAccessor temporalAccessor;
            if (value instanceof Instant instant) {
                temporalAccessor = ZonedDateTime.ofInstant(instant, zoneId);
            } else if (value instanceof LocalDate localDate) {
                temporalAccessor = ZonedDateTime.of(LocalDateTime.of(localDate, LocalTime.MIDNIGHT), zoneId);
            } else if (value instanceof LocalTime localTime) {
                temporalAccessor = ZonedDateTime.of(LocalDateTime.of(LocalDate.now(), localTime), zoneId);
            } else if (value instanceof LocalDateTime localDateTime) {
                temporalAccessor = ZonedDateTime.of(localDateTime, zoneId);
            } else {
                throw new UnsupportedOperationException("Value is not a temporal accessor.");
            }

            return switch (dateTimeType) {
                case DATE -> {
                    if (formatStyle != null) {
                        yield DateTimeFormatter.ofLocalizedDate(formatStyle).withLocale(locale).format(temporalAccessor);
                    } else {
                        yield DateTimeFormatter.ISO_OFFSET_DATE.format(ZonedDateTime.from(temporalAccessor));
                    }
                }
                case TIME -> {
                    if (formatStyle != null) {
                        yield DateTimeFormatter.ofLocalizedTime(formatStyle).withLocale(locale).format(temporalAccessor);
                    } else {
                        yield DateTimeFormatter.ISO_OFFSET_TIME.format(ZonedDateTime.from(temporalAccessor));
                    }
                }
                case DATE_TIME -> {
                    if (formatStyle != null) {
                        yield DateTimeFormatter.ofLocalizedDateTime(formatStyle).withLocale(locale).format(temporalAccessor);
                    } else {
                        yield DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(ZonedDateTime.from(temporalAccessor));
                    }
                }
            };
        }
    }

    // Marker type enumeration
    private enum MarkerType {
        CONDITIONAL_SECTION_START,
        REPEATING_SECTION_START,
        INVERTED_SECTION_START,
        SECTION_END,
        RESOURCE,
        INCLUDE,
        COMMENT,
        VARIABLE
    }

    // Map iterator
    private static class MapIterator implements Iterator> {
        Iterator> iterator;

        MapIterator(Map map) {
            iterator = map.entrySet().iterator();
        }

        @Override
        public boolean hasNext() {
            return iterator.hasNext();
        }

        @Override
        public Map next() {
            return new AbstractMap<>() {
                Entry entry = iterator.next();

                @Override
                public Object get(Object key) {
                    if (key.equals(KEY_REFERENCE)) {
                        return entry.getKey();
                    } else {
                        var value = entry.getValue();

                        if (value instanceof Map map) {
                            return map.get(key);
                        } else if (key.equals(SELF_REFERENCE)) {
                            return value;
                        } else {
                            return null;
                        }
                    }
                }

                @Override
                public boolean containsKey(Object key) {
                    if (key.equals(KEY_REFERENCE)) {
                        return true;
                    } else {
                        var value = entry.getValue();

                        if (value instanceof Map map) {
                            return map.containsKey(key);
                        } else {
                            return key.equals(SELF_REFERENCE);
                        }
                    }
                }

                @Override
                public Set> entrySet() {
                    throw new UnsupportedOperationException();
                }
            };
        }
    }

    private URL url;
    private ResourceBundle resourceBundle;

    private ContentType contentType = ContentType.MARKUP;

    private Map modifiers = mapOf(
        entry("format", new FormatModifier())
    );

    private Deque> dictionaries = new LinkedList<>();

    private Deque sectionNames = new LinkedList<>();

    private static final int EOF = -1;

    private static final String KEY_REFERENCE = "~";
    private static final String SELF_REFERENCE = ".";

    /**
     * Constructs a new template encoder.
     *
     * @param url
     * The URL of the template.
     */
    public TemplateEncoder(URL url) {
        this(url, null);
    }

    /**
     * Constructs a new template encoder.
     *
     * @param url
     * The URL of the template.
     *
     * @param resourceBundle
     * The resource bundle, or {@code null} for no resource bundle.
     */
    public TemplateEncoder(URL url, ResourceBundle resourceBundle) {
        if (url == null) {
            throw new IllegalArgumentException();
        }

        this.url = url;
        this.resourceBundle = resourceBundle;
    }

    /**
     * Returns the content type.
     *
     * @return
     * The content type.
     */
    public ContentType getContentType() {
        return contentType;
    }

    /**
     * Sets the content type.
     *
     * @param contentType
     * The content type.
     */
    public void setContentType(ContentType contentType) {
        if (contentType == null) {
            throw new IllegalArgumentException();
        }

        this.contentType = contentType;
    }

    /**
     * Associates a custom modifier with the template encoder.
     *
     * @param name
     * The modifier name.
     *
     * @param modifier
     * The custom modifier.
     */
    public void bind(String name, Modifier modifier) {
        if (name == null || modifier == null) {
            throw new IllegalArgumentException();
        }

        modifiers.put(name, modifier);
    }

    @Override
    public void write(Object value, OutputStream outputStream) throws IOException {
        write(value, outputStream, Locale.getDefault());
    }

    /**
     * Writes a value to an output stream.
     *
     * @param value
     * The value to encode.
     *
     * @param outputStream
     * The output stream to write to.
     *
     * @param locale
     * The locale to use when writing the value.
     *
     * @throws IOException
     * If an exception occurs.
     */
    public void write(Object value, OutputStream outputStream, Locale locale) throws IOException {
        write(value, outputStream, locale, TimeZone.getDefault());
    }

    /**
     * Writes a value to an output stream.
     *
     * @param value
     * The value to encode.
     *
     * @param outputStream
     * The output stream to write to.
     *
     * @param locale
     * The locale to use when writing the value.
     *
     * @param timeZone
     * The time zone to use when writing the value.
     *
     * @throws IOException
     * If an exception occurs.
     */
    public void write(Object value, OutputStream outputStream, Locale locale, TimeZone timeZone) throws IOException {
        if (outputStream == null) {
            throw new IllegalArgumentException();
        }

        Writer writer = new OutputStreamWriter(outputStream, getCharset());

        write(value, writer, locale, timeZone);

        writer.flush();
    }

    @Override
    public void write(Object value, Writer writer) throws IOException {
        write(value, writer, Locale.getDefault());
    }

    /**
     * Writes a value to a character stream.
     *
     * @param value
     * The value to encode.
     *
     * @param writer
     * The character stream to write to.
     *
     * @param locale
     * The locale to use when writing the value.
     *
     * @throws IOException
     * If an exception occurs.
     */
    public void write(Object value, Writer writer, Locale locale) throws IOException {
        write(value, writer, locale, TimeZone.getDefault());
    }

    /**
     * Writes a value to a character stream.
     *
     * @param value
     * The value to encode.
     *
     * @param writer
     * The character stream to write to.
     *
     * @param locale
     * The locale to use when writing the value.
     *
     * @param timeZone
     * The time zone to use when writing the value.
     *
     * @throws IOException
     * If an exception occurs.
     */
    public void write(Object value, Writer writer, Locale locale, TimeZone timeZone) throws IOException {
        if (writer == null || locale == null || timeZone == null) {
            throw new IllegalArgumentException();
        }

        if (value != null) {
            try (var inputStream = url.openStream()) {
                Reader reader = new PagedReader(new InputStreamReader(inputStream, getCharset()));

                writer = new BufferedWriter(writer);

                write(value, writer, locale, timeZone, reader);

                writer.flush();
            }
        }
    }

    private void write(Object root, Writer writer, Locale locale, TimeZone timeZone, Reader reader) throws IOException {
        Map dictionary;
        if (root instanceof Map map) {
            dictionary = map;
        } else {
            dictionary = mapOf(
                entry(SELF_REFERENCE, root)
            );
        }

        dictionaries.push(dictionary);

        var c = reader.read();

        while (c != EOF) {
            if (c == '{') {
                c = reader.read();

                if (c == EOF) {
                    continue;
                }

                if (c == '{') {
                    c = reader.read();

                    MarkerType markerType;
                    if (c == '?') {
                        markerType = MarkerType.CONDITIONAL_SECTION_START;
                    } else if (c == '#') {
                        markerType = MarkerType.REPEATING_SECTION_START;
                    } else if (c == '^') {
                        markerType = MarkerType.INVERTED_SECTION_START;
                    } else if (c == '/') {
                        markerType = MarkerType.SECTION_END;
                    } else if (c == '@') {
                        markerType = MarkerType.RESOURCE;
                    } else if (c == '>') {
                        markerType = MarkerType.INCLUDE;
                    } else if (c == '!') {
                        markerType = MarkerType.COMMENT;
                    } else {
                        markerType = MarkerType.VARIABLE;
                    }

                    if (markerType != MarkerType.VARIABLE) {
                        c = reader.read();
                    }

                    var markerBuilder = new StringBuilder();

                    while (c != ':' && c != '}' && c != EOF) {
                        markerBuilder.append((char)c);

                        c = reader.read();
                    }

                    if (markerBuilder.isEmpty()) {
                        throw new IOException("Invalid marker.");
                    }

                    var marker = markerBuilder.toString();

                    Deque modifierNames = new LinkedList<>();
                    Map modifierArguments = new HashMap<>();

                    if (c == ':') {
                        var modifierNameBuilder = new StringBuilder();
                        var modifierArgumentBuilder = new StringBuilder();

                        var argument = false;

                        while (c != EOF) {
                            c = reader.read();

                            if (c == EOF) {
                                continue;
                            }

                            if (c == ':' || c == '}') {
                                if (modifierNameBuilder.isEmpty()) {
                                    throw new IOException("Invalid modifier name.");
                                }

                                var modifierName = modifierNameBuilder.toString();

                                modifierNames.add(modifierName);
                                modifierArguments.put(modifierName, modifierArgumentBuilder.toString());

                                modifierNameBuilder.setLength(0);
                                modifierArgumentBuilder.setLength(0);

                                argument = false;

                                if (c == '}') {
                                    break;
                                }
                            } else if (c == '=') {
                                argument = true;
                            } else if (argument) {
                                modifierArgumentBuilder.append((char)c);
                            } else {
                                modifierNameBuilder.append((char)c);
                            }
                        }
                    }

                    if (c == EOF) {
                        throw new IOException("Unexpected end of character stream.");
                    }

                    c = reader.read();

                    if (c != '}') {
                        throw new IOException("Improperly terminated marker.");
                    }

                    switch (markerType) {
                        case CONDITIONAL_SECTION_START -> {
                            sectionNames.push(marker);

                            var value = getMarkerValue(marker);

                            if (value != null
                                && (!(value instanceof Boolean flag) || flag)
                                && (!(value instanceof String string) || !string.isEmpty())
                                && (!(value instanceof Iterable iterable) || iterable.iterator().hasNext())) {
                                write(value, writer, locale, timeZone, reader);
                            } else {
                                write(null, new NullWriter(), locale, timeZone, reader);
                            }

                            sectionNames.pop();

                        }
                        case REPEATING_SECTION_START -> {
                            String separator = null;

                            var n = marker.length();

                            if (marker.charAt(n - 1) == ']') {
                                var i = marker.lastIndexOf('[');

                                if (i != -1) {
                                    separator = marker.substring(i + 1, n - 1);

                                    marker = marker.substring(0, i);
                                }
                            }

                            sectionNames.push(marker);

                            var value = getMarkerValue(marker);

                            Iterator iterator;
                            if (value == null) {
                                iterator = Collections.emptyIterator();
                            } else if (value instanceof Iterable iterable) {
                                iterator = iterable.iterator();
                            } else if (value instanceof Map map) {
                                iterator = new MapIterator(map);
                            } else {
                                throw new IOException("Invalid section element.");
                            }

                            if (iterator.hasNext()) {
                                var i = 0;

                                while (iterator.hasNext()) {
                                    var element = iterator.next();

                                    if (iterator.hasNext()) {
                                        reader.mark(0);
                                    }

                                    if (i > 0 && separator != null) {
                                        writer.append(separator);
                                    }

                                    write(element, writer, locale, timeZone, reader);

                                    if (iterator.hasNext()) {
                                        reader.reset();
                                    }

                                    i++;
                                }
                            } else {
                                write(null, new NullWriter(), locale, timeZone, reader);
                            }

                            sectionNames.pop();

                        }
                        case INVERTED_SECTION_START -> {
                            sectionNames.push(marker);

                            var value = getMarkerValue(marker);

                            if (value == null
                                || (value instanceof Boolean flag && !flag)
                                || (value instanceof String string && string.isEmpty())
                                || (value instanceof Iterable iterable && !iterable.iterator().hasNext())) {
                                write(value, writer, locale, timeZone, reader);
                            } else {
                                write(null, new NullWriter(), locale, timeZone, reader);
                            }

                            sectionNames.pop();

                        }
                        case SECTION_END -> {
                            if (!sectionNames.peek().equals(marker)) {
                                throw new IOException("Invalid closing section marker.");
                            }

                            dictionaries.pop();

                            return;
                        }
                        case RESOURCE -> {
                            if (resourceBundle == null) {
                                throw new IllegalStateException("Missing resource bundle.");
                            }

                            Object value;
                            try {
                                value = resourceBundle.getObject(marker);
                            } catch (MissingResourceException exception) {
                                value = marker;
                            }

                            if (contentType.modifier != null) {
                                value = contentType.modifier.apply(value, null, locale, timeZone);
                            }

                            writer.append(value.toString());
                        }
                        case INCLUDE -> {
                            if (root != null) {
                                var url = new URL(this.url, marker);

                                try (var inputStream = url.openStream()) {
                                    write(dictionary, writer, locale, timeZone, new PagedReader(new InputStreamReader(inputStream)));
                                }
                            }

                        }
                        case COMMENT -> {
                            // No-op
                        }
                        case VARIABLE -> {
                            var value = getMarkerValue(marker);

                            if (value != null) {
                                for (var modifierName : modifierNames) {
                                    var modifier = modifiers.get(modifierName);

                                    if (modifier == null) {
                                        throw new IOException("Invalid modifier.");
                                    }

                                    value = modifier.apply(value, modifierArguments.get(modifierName), locale, timeZone);
                                }

                                if (contentType.modifier != null) {
                                    value = contentType.modifier.apply(value, null, locale, timeZone);
                                }

                                writer.append(value.toString());
                            }
                        }
                    }
                } else {
                    writer.append('{');
                    writer.append((char)c);
                }
            } else {
                writer.append((char)c);
            }

            c = reader.read();
        }

        dictionaries.pop();
    }

    private Object getMarkerValue(String name) {
        List path = new LinkedList<>(Arrays.asList(name.split("/")));

        for (var dictionary : dictionaries) {
            if (!dictionary.containsKey(path.get(0))) {
                continue;
            }

            return valueAt(dictionary, path);
        }

        return null;
    }

    private static Object valueAt(Map root, List path) {
        var value = root.get(path.remove(0));

        if (path.isEmpty()) {
            return value;
        }

        if (value == null) {
            return null;
        } else if (value instanceof Map map) {
            return valueAt(map, path);
        } else {
            throw new IllegalArgumentException("Value is not a map.");
        }
    }
}