ComponentPropertyResolver.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2019 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.wcm.commons.component;

import java.util.Collection;
import java.util.Map;

import org.apache.commons.collections4.IterableUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.apache.sling.api.resource.ResourceUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.osgi.annotation.versioning.ProviderType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.day.cq.wcm.api.Page;
import com.day.cq.wcm.api.PageManager;
import com.day.cq.wcm.api.components.Component;
import com.day.cq.wcm.api.components.ComponentContext;
import com.day.cq.wcm.api.components.ComponentManager;
import com.day.cq.wcm.api.policies.ContentPolicy;
import com.day.cq.wcm.api.policies.ContentPolicyManager;

import io.wcm.sling.commons.adapter.AdaptTo;

/**
 * Tries to resolve properties with or without inheritance from pages, content policies or component definitions.
 * <p>
 * The lookup can take place in:
 * </p>
 * <ol>
 * <li>Properties of the current page (including the parent pages if inheritance is enabled)</li>
 * <li>Properties from the content policy associated with the current resource</li>
 * <li>Properties defined on the component associated with the current resource (including super components if
 * inheritance is enabled)</li>
 * </ol>
 * <p>
 * By default, only option 3 is enabled (with inheritance).
 * Please make sure to {@link #close()} instances of this class after usage.
 * </p>
 */
@ProviderType
public final class ComponentPropertyResolver implements AutoCloseable {

  private ComponentPropertyResolution componentPropertiesResolution = ComponentPropertyResolution.RESOLVE_INHERIT;
  private ComponentPropertyResolution pagePropertiesResolution = ComponentPropertyResolution.IGNORE;
  private ComponentPropertyResolution contentPolicyResolution = ComponentPropertyResolution.IGNORE;
  private final Page currentPage;
  private final Component currentComponent;
  private final Resource resource;
  private final ResourceResolverFactory resourceResolverFactory;
  private ResourceResolver componentsResourceResolver;
  private boolean initComponentsResourceResolverFailed;

  private static final String SERVICEUSER_SUBSERVICE = "component-properties";

  private static final Logger log = LoggerFactory.getLogger(ComponentPropertyResolver.class);

  /**
   * This constructor is for internal use only, please use {@link ComponentPropertyResolverFactory}.
   * @param page Content page
   * @param resourceResolverFactory Resource resolver factory
   */
  public ComponentPropertyResolver(@NotNull Page page,
      @Nullable ResourceResolverFactory resourceResolverFactory) {
    this(page.getContentResource(), resourceResolverFactory);
  }

  /**
   * This constructor is for internal use only, please use {@link ComponentPropertyResolverFactory}.
   * @param resource Content resource
   * @param resourceResolverFactory Resource resolver factory
   */
  public ComponentPropertyResolver(@NotNull Resource resource,
      @Nullable ResourceResolverFactory resourceResolverFactory) {
    this(resource, false, resourceResolverFactory);
  }

  /**
   * This constructor is for internal use only, please use {@link ComponentPropertyResolverFactory}.
   * @param resource Content resource
   * @param ensureResourceType Ensure the given resource has a resource type.
   *          If this is not the case, try to find the closest parent resource which has a resource type.
   * @param resourceResolverFactory Resource resolver factory
   */
  public ComponentPropertyResolver(@NotNull Resource resource, boolean ensureResourceType,
      @Nullable ResourceResolverFactory resourceResolverFactory) {
    Resource contextResource = null;
    if (ensureResourceType) {
      // find closest parent resource that has a resource type (and not nt:unstructured)
      contextResource = getResourceWithResourceType(resource);
    }
    if (contextResource == null) {
      contextResource = resource;
    }

    ResourceResolver resourceResolver = contextResource.getResourceResolver();
    PageManager pageManager = AdaptTo.notNull(resourceResolver, PageManager.class);
    this.currentPage = pageManager.getContainingPage(contextResource);
    if (hasResourceType(contextResource)) {
      ComponentManager componentManager = AdaptTo.notNull(resourceResolver, ComponentManager.class);
      this.currentComponent = componentManager.getComponentOfResource(contextResource);
    }
    else {
      this.currentComponent = null;
    }
    this.resource = contextResource;
    this.resourceResolverFactory = resourceResolverFactory;
  }

  /**
   * This constructor is for internal use only, please use {@link ComponentPropertyResolverFactory}.
   * @param wcmComponentContext WCM component context
   * @param resourceResolverFactory Resource resolver factory
   */
  public ComponentPropertyResolver(@NotNull ComponentContext wcmComponentContext,
      @Nullable ResourceResolverFactory resourceResolverFactory) {
    this.currentPage = wcmComponentContext.getPage();
    this.currentComponent = wcmComponentContext.getComponent();
    this.resource = wcmComponentContext.getResource();
    this.resourceResolverFactory = resourceResolverFactory;
  }

  /**
   * Lookup for content resource associated with the page component (resource type).
   * @param page Content page
   * @deprecated Please use {@link ComponentPropertyResolverFactory}.
   */
  @Deprecated(since = "1.6.0")
  public ComponentPropertyResolver(@NotNull Page page) {
    this(page, null);
  }

  /**
   * Lookup for content resource associated with a component (resource type).
   * @param resource Content resource
   * @deprecated Please use {@link ComponentPropertyResolverFactory}.
   */
  @Deprecated(since = "1.6.0")
  public ComponentPropertyResolver(@NotNull Resource resource) {
    this(resource, null);
  }

  /**
   * Lookup for content resource associated with a component (resource type).
   * @param resource Content resource
   * @param ensureResourceType Ensure the given resource has a resource type.
   *          If this is not the case, try to find the closest parent resource which has a resource type.
   * @deprecated Please use {@link ComponentPropertyResolverFactory}.
   */
  @Deprecated(since = "1.6.0")
  public ComponentPropertyResolver(@NotNull Resource resource, boolean ensureResourceType) {
    this(resource, ensureResourceType, null);
  }

  /**
   * Lookup with content resource associated with a component (resource type).
   * @param wcmComponentContext WCM component context
   * @deprecated Please use {@link ComponentPropertyResolverFactory}.
   */
  @Deprecated(since = "1.6.0")
  public ComponentPropertyResolver(@NotNull ComponentContext wcmComponentContext) {
    this(wcmComponentContext, null);
  }

  private static boolean hasResourceType(@NotNull Resource resource) {
    return StringUtils.isNotEmpty(resource.getResourceType());
  }

  @SuppressWarnings({ "null", "java:S2589" }) // extra null checks for backward compatibility
  private static @Nullable Resource getResourceWithResourceType(@Nullable Resource resource) {
    if (resource == null) {
      return null;
    }
    String resourceType = resource.getResourceType();
    if (resourceType != null && isPathBasedResourceType(resourceType)) {
      return resource;
    }
    return getResourceWithResourceType(resource.getParent());
  }

  /**
   * Checks if the resource type is a path pointing to a component resource in the repository
   * (where we can lookup properties to inherit from).
   * @param resourceType Resource type
   * @return true for path-based resource types
   */
  private static boolean isPathBasedResourceType(@NotNull String resourceType) {
    return StringUtils.contains(resourceType, "/");
  }

  /**
   * Configure if properties should be resolved in component properties, and with or without inheritance.
   * Default mode is {@link ComponentPropertyResolution#RESOLVE_INHERIT}.
   * @param resolution Resolution mode
   * @return this
   */
  public ComponentPropertyResolver componentPropertiesResolution(@NotNull ComponentPropertyResolution resolution) {
    this.componentPropertiesResolution = resolution;
    return this;
  }

  /**
   * Configure if properties should be resolved in content page properties, and with or without inheritance.
   * Default mode is {@link ComponentPropertyResolution#IGNORE}.
   * @param resolution Resolution mode
   * @return this
   */
  public ComponentPropertyResolver pagePropertiesResolution(@NotNull ComponentPropertyResolution resolution) {
    this.pagePropertiesResolution = resolution;
    return this;
  }

  /**
   * Configure if properties should be resolved from content policies mapped for the given resource.
   * No explicit inheritance mode is supported, so {@link ComponentPropertyResolution#RESOLVE_INHERIT}
   * has the same effect as {@link ComponentPropertyResolution#RESOLVE} in this case.
   * Default mode is {@link ComponentPropertyResolution#IGNORE}.
   * @param resolution Resolution mode
   * @return this
   */
  public ComponentPropertyResolver contentPolicyResolution(@NotNull ComponentPropertyResolution resolution) {
    this.contentPolicyResolution = resolution;
    return this;
  }

  /**
   * Get property.
   * @param name Property name
   * @param type Property type
   * @param <T> Parameter type
   * @return Property value or null if not set
   */
  public @Nullable <T> T get(@NotNull String name, @NotNull Class<T> type) {
    @Nullable
    T value = getPageProperty(currentPage, name, type);
    if (value == null) {
      value = getContentPolicyProperty(name, type);
    }
    if (value == null) {
      value = getComponentProperty(currentComponent, name, type);
    }
    return value;
  }

  /**
   * Get property.
   * @param name Property name
   * @param defaultValue Default value
   * @param <T> Parameter type
   * @return Property value or default value if not set
   */
  public @NotNull <T> T get(@NotNull String name, @NotNull T defaultValue) {
    @Nullable
    @SuppressWarnings("unchecked")
    T value = get(name, (Class<T>)defaultValue.getClass());
    if (value != null) {
      return value;
    }
    else {
      return defaultValue;
    }
  }

  private @Nullable <T> T getComponentProperty(@Nullable Component component,
      @NotNull String name, @NotNull Class<T> type) {
    if (componentPropertiesResolution == ComponentPropertyResolution.IGNORE || component == null) {
      return null;
    }
    @Nullable
    T result;
    if (StringUtils.contains(name, "/")) {
      // if a property in child resource is addressed get property value via local resource
      // because the map behind the getProperties() method does not support child resource access
      String childResourcePath = StringUtils.substringBeforeLast(name, "/");
      String localPropertyName = StringUtils.substringAfterLast(name, "/");
      Resource childResource = getLocalComponentResource(component, childResourcePath);
      result = ResourceUtil.getValueMap(childResource).get(localPropertyName, type);
    }
    else {
      result = component.getProperties().get(name, type);
    }
    if (result == null && componentPropertiesResolution == ComponentPropertyResolution.RESOLVE_INHERIT) {
      result = getComponentProperty(component.getSuperComponent(), name, type);
    }
    return result;
  }

  private @Nullable <T> T getPageProperty(@Nullable Page page,
      @NotNull String name, @NotNull Class<T> type) {
    if (pagePropertiesResolution == ComponentPropertyResolution.IGNORE || page == null) {
      return null;
    }
    @Nullable
    T result = page.getProperties().get(name, type);
    if (result == null && pagePropertiesResolution == ComponentPropertyResolution.RESOLVE_INHERIT) {
      result = getPageProperty(page.getParent(), name, type);
    }
    return result;
  }

  private @Nullable <T> T getContentPolicyProperty(@NotNull String name, @NotNull Class<T> type) {
    if (contentPolicyResolution == ComponentPropertyResolution.IGNORE || resource == null) {
      return null;
    }
    ContentPolicy policy = getPolicy(resource);
    if (policy != null) {
      return policy.getProperties().get(name, type);
    }
    return null;
  }

  /**
   * Get list of child resources.
   * @param name Child node name
   * @return List of child resources or null if not set.
   */
  public @Nullable Collection<Resource> getResources(@NotNull String name) {
    Collection<Resource> list = getPageResources(currentPage, name);
    if (list == null) {
      list = getContentPolicyResources(name);
    }
    if (list == null) {
      list = getComponentResources(currentComponent, name);
    }
    return list;
  }

  private @Nullable Collection<Resource> getComponentResources(@Nullable Component component, @NotNull String name) {
    if (componentPropertiesResolution == ComponentPropertyResolution.IGNORE || component == null) {
      return null;
    }
    Collection<Resource> result = getResources(getLocalComponentResource(component, name));
    if (result == null && componentPropertiesResolution == ComponentPropertyResolution.RESOLVE_INHERIT) {
      result = getComponentResources(component.getSuperComponent(), name);
    }
    return result;
  }

  private @Nullable Collection<Resource> getPageResources(@Nullable Page page, @NotNull String name) {
    if (pagePropertiesResolution == ComponentPropertyResolution.IGNORE || page == null) {
      return null;
    }
    Collection<Resource> result = getResources(page.getContentResource(name));
    if (result == null && pagePropertiesResolution == ComponentPropertyResolution.RESOLVE_INHERIT) {
      result = getPageResources(page.getParent(), name);
    }
    return result;
  }

  @SuppressWarnings("PMD.ReturnEmptyCollectionRatherThanNull")
  private @Nullable Collection<Resource> getContentPolicyResources(@NotNull String name) {
    if (contentPolicyResolution == ComponentPropertyResolution.IGNORE || resource == null) {
      return null;
    }
    ContentPolicy policy = getPolicy(resource);
    if (policy != null) {
      Resource policyResource = policy.adaptTo(Resource.class);
      if (policyResource != null) {
        return getResources(policyResource.getChild(name));
      }
    }
    return null;
  }

  private @Nullable Collection<Resource> getResources(@Nullable Resource parent) {
    if (parent == null) {
      return null;
    }
    Collection<Resource> children = IterableUtils.toList(parent.getChildren());
    if (children.isEmpty()) {
      return null;
    }
    return children;
  }

  /**
   * Get content policy via policy manager.
   * @param resource Content resource
   * @return Policy or null
   */
  private static @Nullable ContentPolicy getPolicy(@NotNull Resource resource) {
    ContentPolicyManager policyManager = AdaptTo.notNull(resource.getResourceResolver(), ContentPolicyManager.class);
    return policyManager.getPolicy(resource);
  }

  /**
   * Get local child resource for component, with a special handling for publish environments where
   * the local child resources for components below /apps are not accessible for everyone.
   * @param component Component
   * @param childResourcePath Child resource path
   * @return Resource or null
   */
  private @Nullable Resource getLocalComponentResource(@NotNull Component component,
      @NotNull String childResourcePath) {
    if (componentsResourceResolver == null
        && resourceResolverFactory != null
        && !initComponentsResourceResolverFailed) {
      try {
        componentsResourceResolver = resourceResolverFactory.getServiceResourceResolver(
            Map.of(ResourceResolverFactory.SUBSERVICE, SERVICEUSER_SUBSERVICE));
      }
      catch (LoginException ex) {
        initComponentsResourceResolverFailed = true;
        if (log.isDebugEnabled()) {
          log.debug("Unable to get resource resolver for accessing local component resource, "
              + "please make sure to grant access to system user 'sling-scripting' for "
              + "bundle 'io.wcm.wcm.commons', subservice '{}'.", SERVICEUSER_SUBSERVICE, ex);
        }
      }
    }
    if (componentsResourceResolver != null) {
      String resourcePath = component.getPath() + "/" + childResourcePath;
      return componentsResourceResolver.getResource(resourcePath);
    }
    // fallback implementation for previous behavior - this will usually not work in publish instances
    return component.getLocalResource(childResourcePath);
  }

  @Override
  public void close() {
    if (componentsResourceResolver != null) {
      componentsResourceResolver.close();
    }
  }

}