ConfigurationSettingJsonDeserializer.java

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package com.azure.data.appconfiguration.implementation;

import com.azure.core.util.logging.ClientLogger;
import com.azure.core.util.serializer.JacksonAdapter;
import com.azure.data.appconfiguration.models.ConfigurationSetting;
import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting;
import com.azure.data.appconfiguration.models.FeatureFlagFilter;
import com.azure.data.appconfiguration.models.SecretReferenceConfigurationSetting;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.module.SimpleModule;

import java.io.IOException;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.CLIENT_FILTERS;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.CONDITIONS;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.CONTENT_TYPE;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.DESCRIPTION;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.DISPLAY_NAME;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.ENABLED;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.ID;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.KEY;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.LAST_MODIFIED;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.LOCKED;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.NAME;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.PARAMETERS;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.URI;
import static com.azure.data.appconfiguration.implementation.ConfigurationSettingJsonSerializer.VALUE;

/**
 * Custom JSON deserializer for {@link ConfigurationSetting} and its derived classes,
 * {@link SecretReferenceConfigurationSetting} and {@link FeatureFlagConfigurationSetting}.
 */
public final class ConfigurationSettingJsonDeserializer extends JsonDeserializer<ConfigurationSetting> {
    private static final ClientLogger LOGGER = new ClientLogger(ConfigurationSettingJsonDeserializer.class);
    private static final String FEATURE_FLAG_CONTENT_TYPE = "application/vnd.microsoft.appconfig.ff+json;charset=utf-8";
    private static final String SECRET_REFERENCE_CONTENT_TYPE =
        "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8";

    private static final JacksonAdapter MAPPER;
    private static final SimpleModule MODULE;
    static {
        MAPPER = (JacksonAdapter) JacksonAdapter.createDefaultSerializerAdapter();
        MODULE = new SimpleModule()
                     .addDeserializer(ConfigurationSetting.class, new ConfigurationSettingJsonDeserializer())
                     .addDeserializer(SecretReferenceConfigurationSetting.class,
                         configurationSettingSubclassDeserializer(SecretReferenceConfigurationSetting.class))
                     .addDeserializer(FeatureFlagConfigurationSetting.class,
                         configurationSettingSubclassDeserializer(FeatureFlagConfigurationSetting.class));
    }

    /**
     * Gets a module wrapping this deserializer as an adapter for the Jackson
     * ObjectMapper.
     *
     * @return a simple module to be plugged onto Jackson ObjectMapper.
     */
    public static SimpleModule getModule() {
        return MODULE;
    }

    @Override
    public ConfigurationSetting deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        return read(ctxt.readTree(p));
    }

    private static ConfigurationSetting read(JsonNode node) {
        String key = null;
        final JsonNode keyNode = node.get(KEY);
        if (keyNode != null && !keyNode.isNull()) {
            key = keyNode.asText();
        }

        final JsonNode contentTypeNode = node.get(CONTENT_TYPE);
        String contentType = null;
        if (contentTypeNode != null && !contentTypeNode.isNull()) {
            contentType = contentTypeNode.asText();
        }

        final ConfigurationSetting baseSetting = readConfigurationSetting(node);
        try {
            if (key != null && key.startsWith(FeatureFlagConfigurationSetting.KEY_PREFIX)
                    && FEATURE_FLAG_CONTENT_TYPE.equals(contentType)) {
                return readFeatureFlagConfigurationSetting(node, baseSetting);
            } else if (SECRET_REFERENCE_CONTENT_TYPE.equals(contentType)) {
                return readSecretReferenceConfigurationSetting(node, baseSetting);
            }
        } catch (Exception exception) {
            LOGGER.info("The setting is neither a 'FeatureFlagConfigurationSetting' nor "
                + "'SecretReferenceConfigurationSetting', return the setting as 'ConfigurationSetting'. "
                + "Error: ", exception);
        }
        return baseSetting;
    }

    private static SecretReferenceConfigurationSetting readSecretReferenceConfigurationSetting(JsonNode settingNode,
        ConfigurationSetting baseSetting) {

        final JsonNode valueNode = settingNode.get(VALUE);
        String settingValue = null;
        if (valueNode != null && !valueNode.isNull()) {
            settingValue = valueNode.asText();
        }

        SecretReferenceConfigurationSetting secretReferenceConfigurationSetting =
            readSecretReferenceConfigurationSettingValue(baseSetting.getKey(), settingValue)
                .setValue(settingValue)
                .setLabel(baseSetting.getLabel())
                .setETag(baseSetting.getETag())
                .setContentType(baseSetting.getContentType())
                .setTags(baseSetting.getTags());

        configurationSettingSubclassReflection(secretReferenceConfigurationSetting, settingNode);

        return secretReferenceConfigurationSetting;
    }

    private static ConfigurationSetting readConfigurationSetting(JsonNode setting) {
        try {
            return MAPPER.serializer().treeToValue(setting, ConfigurationSetting.class);
        } catch (JsonProcessingException exception) {
            throw LOGGER.logExceptionAsError(new RuntimeException(exception));
        }
    }

    private static FeatureFlagConfigurationSetting readFeatureFlagConfigurationSetting(JsonNode settingNode,
        ConfigurationSetting baseSetting) {
        final JsonNode valueNode = settingNode.get(VALUE);
        String settingValue = null;
        if (valueNode != null && !valueNode.isNull()) {
            settingValue = valueNode.asText();
        }

        final FeatureFlagConfigurationSetting featureFlagConfigurationSetting =
            readFeatureFlagConfigurationSettingValue(settingValue)
                .setKey(baseSetting.getKey())
                .setValue(settingValue)
                .setLabel(baseSetting.getLabel())
                .setETag(baseSetting.getETag())
                .setContentType(baseSetting.getContentType())
                .setTags(baseSetting.getTags());

        configurationSettingSubclassReflection(featureFlagConfigurationSetting, settingNode);

        return featureFlagConfigurationSetting;
    }

    private static <T extends ConfigurationSetting> ConfigurationSetting configurationSettingSubclassReflection(
        ConfigurationSetting setting, JsonNode settingNode) {

        final JsonNode isLockedNode = settingNode.get(LOCKED);
        boolean locked = false;
        if (isLockedNode != null && !isLockedNode.isNull()) {
            locked = isLockedNode.asBoolean();
        }
        ConfigurationSettingHelper.setReadOnly(setting, locked);


        final JsonNode lastModifiedNode = settingNode.get(LAST_MODIFIED);
        if (lastModifiedNode != null && !lastModifiedNode.isNull()) {
            String lastModifiedText = lastModifiedNode.asText();
            OffsetDateTime lastModified = OffsetDateTime.parse(lastModifiedText, DateTimeFormatter.ISO_DATE_TIME);
            ConfigurationSettingHelper.setLastModified(setting, lastModified);
        }
        return setting;
    }

    /**
     * Given a JSON format string {@code settingValue}, deserializes it into a {@link JsonNode} and returns a
     * {@link SecretReferenceConfigurationSetting} object.
     *
     * @param key the {@code key} property of setting.
     * @param settingValue a JSON format string that represents the {@code value} property of setting.
     * @return A {@link SecretReferenceConfigurationSetting} object.
     */
    public static SecretReferenceConfigurationSetting readSecretReferenceConfigurationSettingValue(String key,
        String settingValue) {
        final JsonNode settingValueNode = toJsonNode(settingValue);

        final JsonNode uriNode = settingValueNode.get(URI);
        String secretID = null;
        if (uriNode != null && !uriNode.isNull()) {
            secretID = uriNode.asText(); // uri node contains the secret ID value
        }
        return new SecretReferenceConfigurationSetting(key, secretID);
    }

    /**
     * Given a JSON format string {@code settingValue}, deserializes it into a {@link JsonNode} and returns a
     * {@link FeatureFlagConfigurationSetting} object.
     *
     * @param settingValue a JSON format string that represents the {@code value} property of setting.
     * @return A {@link FeatureFlagConfigurationSetting} object which converted from the {@code settingValue}.
     */
    public static FeatureFlagConfigurationSetting readFeatureFlagConfigurationSettingValue(String settingValue) {
        JsonNode valueNode = toJsonNode(settingValue);

        final JsonNode featureIdNode = valueNode.get(ID);
        String featureId = null;
        if (featureIdNode != null && !featureIdNode.isNull()) {
            featureId = featureIdNode.asText();
        }

        final JsonNode descriptionNode = valueNode.get(DESCRIPTION);
        String description = null;
        if (descriptionNode != null && !descriptionNode.isNull()) {
            description = descriptionNode.asText();
        }

        final JsonNode displayNameNode = valueNode.get(DISPLAY_NAME);
        String displayName = null;
        if (displayNameNode != null && !displayNameNode.isNull()) {
            displayName = displayNameNode.asText();
        }

        final JsonNode isEnabledNode = valueNode.get(ENABLED);
        boolean isEnabled = false;
        if (isEnabledNode != null && !isEnabledNode.isNull()) {
            isEnabled = isEnabledNode.asBoolean();
        }

        final JsonNode conditionsNode = valueNode.get(CONDITIONS);
        List<FeatureFlagFilter> filters = null;
        if (conditionsNode != null && !conditionsNode.isNull()) {
            filters = readConditions(conditionsNode);
        }

        return new FeatureFlagConfigurationSetting(featureId, isEnabled)
                   .setDescription(description)
                   .setDisplayName(displayName)
                   .setClientFilters(filters);
    }

    private static List<FeatureFlagFilter> readConditions(JsonNode conditionsNode) {
        JsonNode clientFiltersNode = conditionsNode.get(CLIENT_FILTERS);
        if (clientFiltersNode == null || clientFiltersNode.isNull()) {
            return Collections.emptyList();
        }
        return readFeatureFlagFilters(clientFiltersNode);
    }

    private static List<FeatureFlagFilter> readFeatureFlagFilters(JsonNode featureFlagFilters) {
        List<FeatureFlagFilter> filters = new ArrayList<>();
        featureFlagFilters.forEach(filter -> filters.add(readFeatureFlagFilter(filter)));
        return filters;
    }

    private static FeatureFlagFilter readFeatureFlagFilter(JsonNode filter) {
        String name = null;
        final JsonNode filterNameNode = filter.get(NAME);
        if (filterNameNode != null && !filterNameNode.isNull()) {
            name = filterNameNode.asText();
        }

        final FeatureFlagFilter flagFilter = new FeatureFlagFilter(name);

        final JsonNode parametersNode = filter.get(PARAMETERS);
        if (parametersNode != null && !parametersNode.isNull()) {
            flagFilter.setParameters(readParameters(parametersNode));
        }
        return flagFilter;
    }

    private static Map<String, Object> readParameters(JsonNode node) {
        Map<String, Object> additionalProperties = null;
        Iterator<Map.Entry<String, JsonNode>> fieldsIterator = node.fields();
        while (fieldsIterator.hasNext()) {
            Map.Entry<String, JsonNode> field = fieldsIterator.next();
            String propertyName = field.getKey();
            if (additionalProperties == null) {
                additionalProperties = new HashMap<>();
            }

            additionalProperties.put(propertyName, readAdditionalPropertyValue(field.getValue()));
        }

        return additionalProperties;
    }

    private static Object readAdditionalPropertyValue(JsonNode node) {
        switch (node.getNodeType()) {
            case STRING:
                return node.asText();
            case NUMBER:
                if (node.isInt()) {
                    return node.asInt();
                } else if (node.isLong()) {
                    return node.asLong();
                } else if (node.isFloat()) {
                    return node.floatValue();
                } else {
                    return node.asDouble();
                }
            case BOOLEAN:
                return node.asBoolean();
            case NULL:
            case MISSING:
                return null;
            case OBJECT:
                Map<String, Object> object = new HashMap<>();
                node.fields().forEachRemaining(
                    field -> object.put(field.getKey(), readAdditionalPropertyValue(field.getValue())));
                return object;
            case ARRAY:
                List<Object> array = new ArrayList<>();
                node.forEach(element -> array.add(readAdditionalPropertyValue(element)));

                return array;
            default:
                throw LOGGER.logExceptionAsError(new IllegalStateException(
                    String.format("Unsupported additional property type %s.", node.getNodeType())));
        }
    }

    private static <T extends ConfigurationSetting> JsonDeserializer<T> configurationSettingSubclassDeserializer(
        Class<T> subclass) {
        return new JsonDeserializer<T>() {
            @Override
            public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
                return subclass.cast(read(ctxt.readTree(p)));
            }
        };
    }

    private static JsonNode toJsonNode(String settingValue) {
        try {
            return MAPPER.serializer().readTree(settingValue);
        } catch (JsonProcessingException e) {
            throw LOGGER.logExceptionAsError(new IllegalStateException(e));
        }
    }
}