ProtoTypeOptions.java

package net.morimekta.proto.gson;

import net.morimekta.collect.UnmodifiableMap;
import net.morimekta.collect.UnmodifiableSet;
import net.morimekta.proto.utils.ProtoTypeRegistry;
import net.morimekta.strings.NamingUtil;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

import static net.morimekta.collect.UnmodifiableMap.mapOf;
import static net.morimekta.collect.UnmodifiableMap.toMap;
import static net.morimekta.collect.UnmodifiableSet.setOf;
import static net.morimekta.collect.util.SetOperations.subtract;
import static net.morimekta.collect.util.SetOperations.union;
import static net.morimekta.proto.gson.ProtoTypeOptions.Option.FAIL_ON_NULL_VALUE;
import static net.morimekta.proto.gson.ProtoTypeOptions.Option.WRITE_DURATION_AS_STRING;
import static net.morimekta.proto.gson.ProtoTypeOptions.Option.WRITE_TIMESTAMP_AS_ISO;
import static net.morimekta.proto.gson.ProtoTypeOptions.Option.WRITE_UNPACKED_ANY;
import static net.morimekta.strings.NamingUtil.Format.CAMEL;

/**
 * Class holding options to be used with proto handling.
 */
public final class ProtoTypeOptions {
    /**
     * Boolean options. All are false by default, and are enabled when needed.
     */
    public enum Option {
        /**
         * Fail when encountering an enum value (from string) that is unknown.
         */
        FAIL_ON_UNKNOWN_ENUM,
        /**
         * Fail when encountering an unknown field on a message.
         */
        FAIL_ON_UNKNOWN_FIELD,
        /**
         * Fail when setting null values to fields.
         */
        FAIL_ON_NULL_VALUE,

        /**
         * Ignore unknown types on <code>google.protobuf.Any</code> when parsing unwrapped
         * messages.
         */
        IGNORE_UNKNOWN_ANY_TYPE,
        /**
         * Use a lenient json reader when parsing messages.
         */
        LENIENT_READER,

        /**
         * If set to true will write field names using the field ID.
         */
        WRITE_FIELD_AS_NUMBER,
        /**
         * If set to true will write enum values using the enum number value.
         */
        WRITE_ENUM_AS_NUMBER,
        /**
         * Allows using an unwrapped JSON in place of the binary content of an
         * Any struct.
         */
        WRITE_UNPACKED_ANY,
        /**
         * Allow using the <code>json.compact</code> compact notation for
         * messages. If set to true the messages will be encoded as an array
         * instead of an object.
         */
        WRITE_COMPACT_MESSAGE,
        /**
         * Write google.proto.Timestamp values as ISO timestamp.
         */
        WRITE_TIMESTAMP_AS_ISO,
        /**
         * Write google.proto.Duration as string value.
         */
        WRITE_DURATION_AS_STRING,
    }

    /**
     * String value keys that can be modified through the options.
     */
    public enum Value {
        /**
         * When serializing Any as unpacked objects, use this field name for the
         * type. Defaults to same as default JsonFormat.
         */
        ANY_TYPE_FIELD_NAME("@type"),
        /**
         * When serializing Any as unpacked objects, use this prefix before the
         * full name of the type. Defaults to same as proto default for Any.
         */
        ANY_TYPE_PREFIX("type.googleapis.com/"),
        ;

        private final String defaultValue;

        Value(String defaultValue) {
            this.defaultValue = defaultValue;
        }
    }

    private final Set<Option>                    options;
    private final UnmodifiableMap<Value, String> values;
    private final ProtoTypeRegistry              registry;

    /**
     * Default instance of options.
     */
    public ProtoTypeOptions() {
        this(setOf(FAIL_ON_NULL_VALUE, WRITE_UNPACKED_ANY, WRITE_TIMESTAMP_AS_ISO, WRITE_DURATION_AS_STRING),
             mapOf(),
             ProtoTypeRegistry.newBuilder().build());
    }

    /**
     * Options set with initial values.
     *
     * @param options  The boolean options.
     * @param values   The string values.
     * @param registry The type registry for the options.
     */
    public ProtoTypeOptions(Set<Option> options, Map<Value, String> values, ProtoTypeRegistry registry) {
        this.options = UnmodifiableSet.asSet(options);
        this.values = UnmodifiableMap.asMap(values);
        this.registry = registry;
    }

    /**
     * @return The associated type registry.
     */
    public ProtoTypeRegistry getRegistry() {
        return registry;
    }

    /**
     * @param registry A type registry.
     * @return Proto options with the associated registry.
     */
    public ProtoTypeOptions withRegistry(ProtoTypeRegistry registry) {
        return new ProtoTypeOptions(options, values, registry);
    }

    /**
     * Check if an option is enabled.
     *
     * @param option The option to check.
     * @return True if enabled, false otherwise.
     */
    public boolean isEnabled(Option option) {
        return options.contains(option);
    }

    /**
     * Get a configurable string value.
     *
     * @param entry The value entry.
     * @return The configured value or its default if not configured.
     */
    public String getValue(Value entry) {
        return values.getOrDefault(entry, entry.defaultValue);
    }

    /**
     * Get options with the option enabled.
     *
     * @param option The option to enable.
     * @return The modified options.
     */
    public ProtoTypeOptions withEnabled(Option option) {
        if (options.contains(option)) {
            return this;
        }
        return new ProtoTypeOptions(union(options, setOf(option)), values, registry);
    }

    /**
     * Get options with set of options enabled.
     *
     * @param options The option to enable.
     * @return The modified options.
     */
    public ProtoTypeOptions withEnabled(Option... options) {
        Set<Option> enableAll = UnmodifiableSet.asSet(options);
        if (this.options.containsAll(enableAll)) {
            return this;
        }
        return new ProtoTypeOptions(union(this.options, enableAll), values, registry);
    }

    /**
     * Get options with option disabled.
     *
     * @param option The option to disable.
     * @return The modified options.
     */
    public ProtoTypeOptions withDisabled(Option option) {
        if (options.contains(option)) {
            return new ProtoTypeOptions(subtract(options, setOf(option)), values, registry);
        }
        return this;
    }

    /**
     * Get options with a value set.
     *
     * @param entry The value entry.
     * @param value The value to be set.
     * @return The modified options.
     */
    public ProtoTypeOptions withValue(Value entry, String value) {
        if (values.getOrDefault(entry, entry.defaultValue).equals(value)) {
            return this;
        }
        if (entry.defaultValue.equals(value)) {
            return withDefaultValue(entry);
        }
        return new ProtoTypeOptions(options, values.withEntry(entry, value), registry);
    }

    /**
     * Get options with a value reset to default.
     *
     * @param entry The entry to reset to default.
     * @return The modified options.
     */
    public ProtoTypeOptions withDefaultValue(Value entry) {
        if (values.containsKey(entry)) {
            return new ProtoTypeOptions(options,
                                        values.entrySet()
                                              .stream()
                                              .filter(e -> !e.getKey().equals(entry))
                                              .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)),
                                        registry);
        }
        return this;
    }

    // ---- Object ----

    @Override
    public String toString() {
        List<String> features = new ArrayList<>();
        this.options.stream().map(o -> NamingUtil.format(o.name(), CAMEL)).forEach(features::add);
        this.values.forEach((entry, value) -> features.add(
                NamingUtil.format(entry.name(), CAMEL) + "=" + value));
        return "ProtoTypeOptions{" + String.join(", ", features) + '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        ProtoTypeOptions that = (ProtoTypeOptions) o;
        return options.equals(that.options) &&
               values.equals(that.values);
    }

    @Override
    public int hashCode() {
        return Objects.hash(ProtoTypeOptions.class, options, values);
    }
}