PropertyIntrospector.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2023 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.genericedit.builder.impl.util;

import java.beans.Introspector;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.drew.lang.annotations.Nullable;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;

/**
 * Detects all properties of given class and returns a Map with the values for each property.
 * Properties marked with Jackson JSON ignore annotations are skipped.
 */
public final class PropertyIntrospector {

  private final Map<String, List<Object>> propertiesMap;

  private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build();
  private static final JavaType MAP_TYPE = OBJECT_MAPPER.getTypeFactory()
      .constructMapType(Map.class, String.class, Object.class);

  private static final Set<String> DEFAULT_IGNORE_METHODS = Set.of("toString", "hashCode", "getClass");
  private static final List<String> DEFAULT_GETTER_PREFIXES = List.of("get", "is");

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

  private PropertyIntrospector(@NotNull Map<String, List<Object>> propertiesMap) {
    this.propertiesMap = propertiesMap;
  }

  /**
   * @return Map with property names and value list for each property.
   */
  public Map<String, List<Object>> getPropertiesMap() {
    return this.propertiesMap;
  }

  /**
   * Gets allowed property names by mapping it using Jackson and get the resulting list of properties.
   * @param instance Instance
   * @return Non-ignored property names
   */
  private static Set<String> getAllowedPropertyNames(@NotNull Object instance) {
    Map<String, Object> map = OBJECT_MAPPER.convertValue(instance, MAP_TYPE);
    return map.keySet();
  }

  /**
   * @return Get properties from sling model via map. The map contains a list of values per key.
   */
  private static @NotNull Map<String, List<Object>> buildPropertiesMap(@NotNull Object instance,
      @NotNull Set<String> allowedProperties) {
    Class<?> clazz = instance.getClass();
    List<Method> getterMethods = Stream.of(clazz.getMethods())
        .filter(method -> method.getParameterTypes().length == 0 && method.getReturnType() != void.class)
        .filter(method -> !DEFAULT_IGNORE_METHODS.contains(method.getName()))
        .collect(Collectors.toList());
    Map<String, List<Object>> result = new TreeMap<>();
    for (Method method : getterMethods) {
      String propertyName = toPropertyName(method, clazz);
      if (allowedProperties.contains(propertyName)) {
        try {
          Object value = method.invoke(instance);
          if (value != null) {
            ValueList valueList = ValueList.from(value);
            result.put(propertyName, valueList.get());
          }
        }
        catch (InvocationTargetException | IllegalAccessException | IllegalArgumentException ex) {
          log.warn("Unable to introspect {}#{}", instance.getClass().getName(), method.getName(), ex);
        }
      }
    }
    return result;
  }

  /**
   * Converts method name to property name. If method has a JsonProperty defined, that is returned.
   * @param method Method
   * @param clazz Class
   * @return Property name
   */
  private static @NotNull String toPropertyName(@NotNull Method method, Class<?> clazz) {
    String name = getJsonPropertyNameAllDeclarations(method, clazz);
    if (name == null) {
      name = method.getName();
      for (String getterPrefix : DEFAULT_GETTER_PREFIXES) {
        if (StringUtils.startsWith(name, getterPrefix)) {
          name = Introspector.decapitalize(StringUtils.substringAfter(name, getterPrefix));
        }
      }
    }
    return name;
  }

  /**
   * Checks for JsonProperty annotation in given method, and all related super classes/interface which
   * implement the same method and may have the annotation set.
   * @param method Method
   * @param clazz Class
   * @return Property name is an annotation was found
   */
  private static @Nullable String getJsonPropertyNameAllDeclarations(@NotNull Method method, Class<?> clazz) {
    for (Class<?> checkClass : getAllTypes(clazz)) {
      try {
        Method checkMethod = checkClass.getDeclaredMethod(method.getName());
        String propertyName = getJsonPropertyName(checkMethod);
        if (propertyName != null) {
          return propertyName;
        }
      }
      catch (NoSuchMethodException ex) {
        // ignore
      }
    }
    return null;
  }

  /**
   * Get all classes for interfaces, interface extensions, super classes and their interfaces for this class.
   * @param clazz Class
   * @return All related classes
   */
  private static @NotNull List<Class<?>> getAllTypes(Class<?> clazz) {
    List<Class<?>> allTypes = new ArrayList<>();
    allTypes.add(clazz);
    for (Class<?> interfaze : clazz.getInterfaces()) {
      allTypes.addAll(getAllTypes(interfaze));
    }
    Class<?> superClass = clazz.getSuperclass();
    if (superClass != null && superClass != Object.class) {
      allTypes.addAll(getAllTypes(superClass));
    }
    return allTypes;
  }

  /**
   * Gets property name from JsonProperty annotation of method.
   * @param method Method
   * @return Property name or null
   */
  @SuppressWarnings("null")
  private static @Nullable String getJsonPropertyName(@NotNull Method method) {
    JsonProperty jsonProperty = method.getAnnotation(JsonProperty.class);
    if (jsonProperty != null && StringUtils.isNotEmpty(jsonProperty.value())) {
      return jsonProperty.value();
    }
    return null;
  }

  /**
   * Create property introspector from instance.
   * @param instance Object instance
   * @return Property introspector
   */
  public static PropertyIntrospector from(Object instance) {
    Map<String, List<Object>> propertiesMap = buildPropertiesMap(instance, getAllowedPropertyNames(instance));
    return new PropertyIntrospector(propertiesMap);
  }

}