SuffixBuilder.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2014 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.handler.url.suffix;

import static io.wcm.handler.url.suffix.impl.UrlSuffixUtil.KEY_VALUE_DELIMITER;
import static io.wcm.handler.url.suffix.impl.UrlSuffixUtil.SUFFIX_PART_DELIMITER;
import static io.wcm.handler.url.suffix.impl.UrlSuffixUtil.decodeKey;
import static io.wcm.handler.url.suffix.impl.UrlSuffixUtil.decodeResourcePathPart;
import static io.wcm.handler.url.suffix.impl.UrlSuffixUtil.decodeValue;
import static io.wcm.handler.url.suffix.impl.UrlSuffixUtil.encodeKeyValuePart;
import static io.wcm.handler.url.suffix.impl.UrlSuffixUtil.encodeResourcePathPart;
import static io.wcm.handler.url.suffix.impl.UrlSuffixUtil.getRelativePath;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.Resource;
import org.jetbrains.annotations.NotNull;
import org.osgi.annotation.versioning.ProviderType;

import com.day.cq.wcm.api.Page;

import io.wcm.handler.url.suffix.impl.ExcludeNamedPartsFilter;
import io.wcm.handler.url.suffix.impl.ExcludeResourcePartsFilter;
import io.wcm.handler.url.suffix.impl.ExcludeSpecificResourceFilter;
import io.wcm.handler.url.suffix.impl.FilterOperators;
import io.wcm.handler.url.suffix.impl.IncludeAllPartsFilter;
import io.wcm.handler.url.suffix.impl.IncludeNamedPartsFilter;
import io.wcm.handler.url.suffix.impl.IncludeResourcePartsFilter;
import io.wcm.sling.commons.adapter.AdaptTo;

/**
 * Builds suffixes to be used in Sling URLs and that can be parsed with {@link SuffixParser}.
 */
@ProviderType
public final class SuffixBuilder {

  private final List<String> initialSuffixParts;
  private final Map<String, Object> parameterMap = new HashMap<>();
  private final List<String> resourcePaths = new ArrayList<>();

  /**
   * Create a {@link SuffixBuilder} which discards all existing suffix state when constructing a new suffix.
   */
  public SuffixBuilder() {
    this.initialSuffixParts = new ArrayList<>();
  }

  /**
   * Create a {@link SuffixBuilder} with a custom {@link SuffixStateKeepingStrategy} (see convenience methods like
   * {@link #thatKeepsResourceParts(SlingHttpServletRequest)} for often-used strategies)
   * @param request Sling request
   * @param stateStrategy the strategy to use to decide which parts of the suffix of the current request needs to be
   *          kept in new constructed links
   */
  public SuffixBuilder(@NotNull SlingHttpServletRequest request, @NotNull SuffixStateKeepingStrategy stateStrategy) {
    this.initialSuffixParts = stateStrategy.getSuffixPartsToKeep(request);
  }

  /**
   * Create a {@link SuffixBuilder} that keeps only the suffix parts matched by the given filter when constructing
   * a new suffix
   * @param request Sling request
   * @param suffixPartFilter the filter that is called for each suffix part
   */
  public SuffixBuilder(@NotNull SlingHttpServletRequest request, @NotNull Predicate<String> suffixPartFilter) {
    this(request, new FilteringSuffixStateStrategy(suffixPartFilter));
  }

  /**
   * @return a {@link SuffixBuilder} that discards all existing suffix state when constructing a new suffix
   */
  public static @NotNull SuffixBuilder thatDiscardsAllSuffixState() {
    return new SuffixBuilder();
  }

  /**
   * @param request Sling request
   * @return a {@link SuffixBuilder} that discards everything but the *resource* parts of the suffix
   */
  public static @NotNull SuffixBuilder thatKeepsResourceParts(@NotNull SlingHttpServletRequest request) {
    Predicate<String> filter = new IncludeResourcePartsFilter();
    return new SuffixBuilder(request, filter);
  }

  /**
   * @param request Sling request
   * @param keysToKeep Keys to keep
   * @return a {@link SuffixBuilder} that keeps only the named key/value-parts defined by pKeysToKeep
   */
  public static @NotNull SuffixBuilder thatKeepsNamedParts(@NotNull SlingHttpServletRequest request,
      @NotNull String @NotNull... keysToKeep) {
    Predicate<String> filter = new IncludeNamedPartsFilter(keysToKeep);
    return new SuffixBuilder(request, filter);
  }

  /**
   * @param request Sling request
   * @param keysToKeep Keys to keep
   * @return a {@link SuffixBuilder} that keeps the named key/value-parts defined by pKeysToKeep and all resource
   *         parts
   */
  public static @NotNull SuffixBuilder thatKeepsNamedPartsAndResources(@NotNull SlingHttpServletRequest request,
      @NotNull String @NotNull... keysToKeep) {
    Predicate<String> filter = FilterOperators.or(new IncludeResourcePartsFilter(), new IncludeNamedPartsFilter(keysToKeep));
    return new SuffixBuilder(request, filter);
  }

  /**
   * @param request Sling request
   * @return a {@link SuffixBuilder} that keeps all parts from the current request's suffix when constructing a new
   *         suffix
   */
  public static @NotNull SuffixBuilder thatKeepsAllParts(@NotNull SlingHttpServletRequest request) {
    return new SuffixBuilder(request, new IncludeAllPartsFilter());
  }

  /**
   * @param request Sling request
   * @return a {@link SuffixBuilder} that will discard the resource parts, but keep all named key/value-parts
   */
  public static @NotNull SuffixBuilder thatDiscardsResourceParts(@NotNull SlingHttpServletRequest request) {
    ExcludeResourcePartsFilter filter = new ExcludeResourcePartsFilter();
    return new SuffixBuilder(request, filter);
  }

  /**
   * @param request Sling request
   * @param keysToDiscard the keys of the named parts to discard
   * @return a {@link SuffixBuilder} that will keep all parts except those named key/value-parts defined by
   *         pKeysToDiscard
   */
  public static @NotNull SuffixBuilder thatDiscardsNamedParts(@NotNull SlingHttpServletRequest request,
      @NotNull String @NotNull... keysToDiscard) {
    return new SuffixBuilder(request, new ExcludeNamedPartsFilter(keysToDiscard));
  }

  /**
   * @param request Sling request
   * @param keysToDiscard the keys of the named parts to discard
   * @return {@link SuffixBuilder} that will discard all resource parts and the named parts defined by pKeysToDiscard
   */
  public static @NotNull SuffixBuilder thatDiscardsResourceAndNamedParts(@NotNull SlingHttpServletRequest request,
      @NotNull String @NotNull... keysToDiscard) {
    Predicate<String> filter = FilterOperators.and(new ExcludeResourcePartsFilter(), new ExcludeNamedPartsFilter(keysToDiscard));
    return new SuffixBuilder(request, filter);
  }

  /**
   * @param request Sling request
   * @param resourcePathToDiscard relative path of the resource to discard
   * @param keysToDiscard the keys of the named parts to discard
   * @return {@link SuffixBuilder} that will discard *one specific resource path* and the named parts defined by
   *         pKeysToDiscard
   */
  public static @NotNull SuffixBuilder thatDiscardsSpecificResourceAndNamedParts(@NotNull SlingHttpServletRequest request,
      @NotNull String resourcePathToDiscard, @NotNull String @NotNull... keysToDiscard) {
    Predicate<String> filter = FilterOperators.and(new ExcludeSpecificResourceFilter(resourcePathToDiscard), new ExcludeNamedPartsFilter(keysToDiscard));
    return new SuffixBuilder(request, filter);
  }

  /**
   * Puts a key-value pair into the suffix.
   * @param key the key
   * @param value the value
   * @return this
   */
  @SuppressWarnings({ "null", "unused", "java:S2589" })
  public @NotNull SuffixBuilder put(@NotNull String key, @NotNull Object value) {
    if (key == null) {
      throw new IllegalArgumentException("Key must not be null");
    }
    if (value != null) {
      validateValueType(value);
      parameterMap.put(key, value);
    }
    return this;
  }

  /**
   * Puts a map of key-value pairs into the suffix.
   * @param map map of key-value pairs
   * @return this
   */
  public @NotNull SuffixBuilder putAll(@NotNull Map<String, Object> map) {
    for (Map.Entry<String, Object> entry : map.entrySet()) {
      put(entry.getKey(), entry.getValue());
    }
    return this;
  }

  private void validateValueType(Object value) {
    Class<?> clazz = value.getClass();
    boolean isValid = (clazz == String.class
        || clazz == Boolean.class
        || clazz == Integer.class
        || clazz == Long.class);
    if (!isValid) {
      throw new IllegalArgumentException("Unsupported value type: " + clazz.getName());
    }
  }

  /**
   * Puts a relative path of a resource into the suffix.
   * @param resource the resource
   * @param suffixBaseResource the base resource used to construct the relative path
   * @return this
   */
  public @NotNull SuffixBuilder resource(@NotNull Resource resource, @NotNull Resource suffixBaseResource) {
    // get relative path to base resource
    String relativePath = getRelativePath(resource, suffixBaseResource);
    resourcePaths.add(relativePath);
    return this;
  }

  /**
   * Constructs a suffix that contains multiple key-value pairs and address resources. Depending on the
   * {@link SuffixStateKeepingStrategy}, the suffix contains
   * further parts from the current request that should be kept when constructing new links.
   * @param resources resources to address
   * @param baseResource base resource to construct relative path
   * @return the suffix containing the map-content as encoded key value-pairs (and eventually other parts)
   */
  public @NotNull SuffixBuilder resources(@NotNull List<Resource> resources, @NotNull Resource baseResource) {
    for (Resource resource : resources) {
      resource(resource, baseResource);
    }
    return this;
  }

  /**
   * Puts a relative path of a page into the suffix.
   * @param page the page
   * @param suffixBasePage the base page used to construct the relative path
   * @return this
   */
  public @NotNull SuffixBuilder page(@NotNull Page page, @NotNull Page suffixBasePage) {
    return resource(AdaptTo.notNull(page, Resource.class), AdaptTo.notNull(suffixBasePage, Resource.class));
  }

  /**
   * Constructs a suffix that contains multiple key-value pairs and address pages. Depending on the
   * {@link SuffixStateKeepingStrategy}, the suffix contains
   * further parts from the current request that should be kept when constructing new links.
   * @param pages pages to address
   * @param suffixBasePage the base page used to construct the relative path
   * @return this
   */
  @SuppressWarnings("null")
  public @NotNull SuffixBuilder pages(@NotNull List<Page> pages, @NotNull Page suffixBasePage) {
    List<Resource> resources = pages.stream()
        .map(page -> page.adaptTo(Resource.class))
        .collect(Collectors.toList());
    return resources(resources, AdaptTo.notNull(suffixBasePage, Resource.class));
  }

  /**
   * Build complete suffix.
   * @return the suffix
   */
  @SuppressWarnings("java:S2692") // 0 index skipped by intention
  public @NotNull String build() {
    SortedMap<String, Object> sortedParameterMap = new TreeMap<>(parameterMap);

    // gather resource paths in a treeset (having them in a defined order helps with caching)
    SortedSet<String> resourcePathsSet = new TreeSet<>();

    // iterate over all parts that should be kept from the current request
    for (String nextPart : initialSuffixParts) {
      // if this is a key-value-part:
      if (nextPart.indexOf(KEY_VALUE_DELIMITER) > 0) {
        String key = decodeKey(nextPart);
        // decode and keep the part if it is not overridden in the given parameter-map
        if (!sortedParameterMap.containsKey(key)) {
          String value = decodeValue(nextPart);
          sortedParameterMap.put(key, value);
        }
      }
      else {
        // decode and keep the resource paths (unless they are specified again in resourcePaths)
        String path = decodeResourcePathPart(nextPart);
        if (!resourcePaths.contains(path)) {
          resourcePathsSet.add(path);
        }
      }
    }

    // copy the resources specified as parameters to the sorted set of paths
    if (resourcePaths != null) {
      resourcePathsSet.addAll(List.copyOf(resourcePaths));
    }

    // gather all suffix parts in this list
    List<String> suffixParts = new ArrayList<>();

    // now encode all resource paths
    for (String path : resourcePathsSet) {
      suffixParts.add(encodeResourcePathPart(path));
    }

    // now encode all entries from the parameter map
    for (Entry<String, Object> entry : sortedParameterMap.entrySet()) {
      Object value = entry.getValue();
      if (value == null) {
        // don't add suffix part if value is null
        continue;
      }
      String encodedKey = encodeKeyValuePart(entry.getKey());
      String encodedValue = encodeKeyValuePart(value.toString());
      suffixParts.add(encodedKey + KEY_VALUE_DELIMITER + encodedValue);
    }

    // finally join these parts to a single string
    return StringUtils.join(suffixParts, SUFFIX_PART_DELIMITER);
  }

}