ContextAwareConfigurationMapperImpl.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2022 wcm.io
 * %%
 * 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.
 * #L%
 */
package io.wcm.siteapi.processor.caconfig.impl;

import static java.util.function.Predicate.not;

import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.SortedSet;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.caconfig.management.ConfigurationCollectionData;
import org.apache.sling.caconfig.management.ConfigurationData;
import org.apache.sling.caconfig.management.ConfigurationManager;
import org.apache.sling.caconfig.spi.metadata.ConfigurationMetadata;
import org.apache.sling.caconfig.spi.metadata.PropertyMetadata;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.osgi.framework.ServiceReference;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.FieldOption;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
import org.osgi.service.component.annotations.ReferencePolicyOption;

import io.wcm.siteapi.processor.caconfig.ContextAwareConfigurationMapper;
import io.wcm.siteapi.processor.caconfig.ContextAwareConfigurationPropertyMapper;
import io.wcm.sling.commons.caservice.ContextAwareServiceCollectionResolver;
import io.wcm.sling.commons.caservice.ContextAwareServiceResolver;

/**
 * Implements {@link ContextAwareConfigurationMapper},
 */
@Component(service = ContextAwareConfigurationMapper.class)
public class ContextAwareConfigurationMapperImpl implements ContextAwareConfigurationMapper {

  // ignore property names with namespaces sling/jcr/cq
  private static final Pattern IGNORED_SYSTEM_PROPERTY_NAMES = Pattern.compile("^(sling|jcr|cq):.*$");

  @Reference
  private ConfigurationManager configManager;

  @Reference(cardinality = ReferenceCardinality.MULTIPLE, fieldOption = FieldOption.UPDATE,
      policy = ReferencePolicy.DYNAMIC, policyOption = ReferencePolicyOption.GREEDY)
  private SortedSet<ServiceReference<ContextAwareConfigurationPropertyMapper<Object>>> propertyMapper = new ConcurrentSkipListSet<>(
      Collections.reverseOrder());

  @Reference
  private ContextAwareServiceResolver serviceResolver;
  private ContextAwareServiceCollectionResolver<ContextAwareConfigurationPropertyMapper<Object>, Void> propertyMapperResolver;

  @Activate
  private void activate() {
    this.propertyMapperResolver = serviceResolver.getCollectionResolver(this.propertyMapper);
  }

  @Deactivate
  private void deactivate() {
    this.propertyMapperResolver.close();
  }


  @Override
  public @Nullable Object get(@NotNull String configName, @NotNull SlingHttpServletRequest request) {
    ConfigurationMetadata metadata = configManager.getConfigurationMetadata(configName);
    if (metadata != null) {
      return build(metadata, request);
    }
    return null;
  }

  /**
   * Build JSON representation of context-aware configuration.
   * @param metadata Configuration Metadata
   * @return Map/List with configuration data, or null if configuration or metadata is not present.
   */
  @Nullable
  private Object build(@NotNull ConfigurationMetadata metadata, @NotNull SlingHttpServletRequest request) {
    Resource contextResource = request.getResource();

    // get property mappers
    Collection<ContextAwareConfigurationPropertyMapper<Object>> mappers = propertyMapperResolver
        .resolveAll(contextResource).collect(Collectors.toList());

    // singleton caconfig
    if (metadata.isSingleton()) {
      ConfigurationData configData = configManager.getConfiguration(
          contextResource, metadata.getName());
      if (configData != null) {
        ConfigSingletonItem item = toSingletonItem(configData, request, mappers);
        if (!item.isEmpty()) {
          return item.toJsonObject();
        }
      }
    }

    // collection caconfig
    else {
      ConfigurationCollectionData configCollectionData = configManager.getConfigurationCollection(
          contextResource, metadata.getName());
      if (!configCollectionData.getItems().isEmpty()) {
        ConfigCollectionItem item = toCollectionItem(configCollectionData.getItems(), request, mappers);
        if (!item.isEmpty()) {
          return item.toJsonObject();
        }
      }
    }

    return null;
  }

  /**
   * Generate collection item for all configuration values.
   */
  private @NotNull ConfigCollectionItem toCollectionItem(@NotNull Collection<ConfigurationData> configurationDatas,
      @NotNull SlingHttpServletRequest request,
      @NotNull Collection<ContextAwareConfigurationPropertyMapper<Object>> mappers) {
    ConfigCollectionItem collectionItem = new ConfigCollectionItem();
    for (ConfigurationData configData : configurationDatas) {
      collectionItem.addItem(toSingletonItem(configData, request, mappers));
    }
    return collectionItem;
  }

  /**
   * Generate singleton item for all configuration values.
   */
  private @NotNull ConfigSingletonItem toSingletonItem(@NotNull ConfigurationData configData,
      @NotNull SlingHttpServletRequest request,
      @NotNull Collection<ContextAwareConfigurationPropertyMapper<Object>> mappers) {
    ConfigSingletonItem item = new ConfigSingletonItem();

    getExportedProperties(configData).forEach(property -> {
      if (property.isRequired()) {
        // mark required property
        item.addRequiredPropertyName(property.getName());
      }
      Object value = property.getValue();
      PropertyMetadata<?> metadata = property.getMetadata();
      if (value != null && metadata != null) {
        if (property.isNestedConfiguration()) {
          // special handling for nested configurations
          value = mapNestedConfiguration(value, metadata, request, mappers);
        }
        else {
          // map property value to target structure
          ContextAwareConfigurationPropertyMapper<Object> mapper = getMatchingMapper(value, metadata, request, mappers);
          if (mapper != null) {
            value = mapValue(value, metadata, request, mapper);
          }
        }
      }
      if (value != null) {
        item.put(property.getName(), value);
      }
    });

    return item;
  }

  /**
   * Calls property mapper. In case of object array, the mapper is called for each individual value.
   */
  private @Nullable Object mapValue(@NotNull Object value, @NotNull PropertyMetadata<?> metadata,
      @NotNull SlingHttpServletRequest request,
      @NotNull ContextAwareConfigurationPropertyMapper<Object> mapper) {
    if (value.getClass().isArray()) {
      List<Object> result = new ArrayList<>();
      int arrayLength = Array.getLength(value);
      for (int i = 0; i < arrayLength; i++) {
        Object valueItem = Array.get(value, i);
        Object mappedItem = mapper.map(valueItem, metadata, request);
        if (mappedItem != null) {
          result.add(mappedItem);
        }
      }
      if (result.isEmpty()) {
        return null;
      }
      else {
        return result;
      }
    }
    else {
      return mapper.map(value, metadata, request);
    }
  }

  /**
   * Get all properties to be exported.
   * Ignore system and hidden properties.
   */
  @SuppressWarnings("null")
  private @NotNull Stream<PropertyInfo> getExportedProperties(@NotNull ConfigurationData configData) {
    return configData.getPropertyNames().stream()
        .filter(propertyName -> !IGNORED_SYSTEM_PROPERTY_NAMES.matcher(propertyName).matches())
        .map(configData::getValueInfo)
        .filter(Objects::nonNull)
        .map(PropertyInfo::new)
        .filter(not(PropertyInfo::isHidden));
  }

  /**
   * Get property mapper that matches for this property.
   */
  private @Nullable ContextAwareConfigurationPropertyMapper<Object> getMatchingMapper(@NotNull Object value,
      @NotNull PropertyMetadata<?> metadata,
      @NotNull SlingHttpServletRequest request,
      @NotNull Collection<ContextAwareConfigurationPropertyMapper<Object>> mappers) {
    return mappers.stream()
        .filter(mapper -> mapper.accept(value, metadata, request))
        .findFirst().orElse(null);
  }

  /**
   * Special handling for nested configs or nested config collections
   */
  private @Nullable Object mapNestedConfiguration(@NotNull Object value,
      @NotNull PropertyMetadata<?> metadata,
      @NotNull SlingHttpServletRequest request,
      @NotNull Collection<ContextAwareConfigurationPropertyMapper<Object>> mappers) {

    if (metadata.getType().isArray()) {
      ConfigurationData[] configDatas = (ConfigurationData[])value;
      if (configDatas.length == 0) {
        return null;
      }
      return toCollectionItem(Arrays.asList(configDatas), request, mappers);
    }
    else {
      ConfigurationData configData = (ConfigurationData)value;
      return toSingletonItem(configData, request, mappers);
    }
  }

}