package com.psddev.cms.view; import com.psddev.cms.image.ImageSize; import com.psddev.dari.util.ClassFinder; import com.psddev.dari.util.Once; import com.psddev.dari.util.Settings; import com.psddev.dari.util.StringUtils; import com.psddev.dari.util.ThreadLocalStack; import com.psddev.dari.util.TypeDefinition; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.beans.IntrospectionException; import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.function.Supplier; import java.util.stream.Collectors; /** * Am unmodifiable Map implementation that uses a view (java bean) as the * backing object for the keys and values within the map. This Map uses the * bean spec to map the getter methods of the backing view to the keys within * the map. */ class ViewMap implements Map<String, Object> { private static final Logger LOGGER = LoggerFactory.getLogger(ViewMap.class); private Map<String, Supplier<Object>> unresolved; private Map<String, Object> resolved; private boolean includeClassName; private Object view; private Once resolver = new Once() { @Override protected void run() throws Exception { // copy keys to new set to prevent concurrent modification exception. new LinkedHashSet<>(ViewMap.this.unresolved.keySet()).forEach(ViewMap.this::get); } }; /** * Creates a new Map backed by the specified view. * * @param view the view to wrap. */ public ViewMap(Object view) { this(view, false); } /** * Creates a new Map backed by the specified view. * * @param view the view to wrap. * @param includeClassName true if class names for each view should be included in the map. */ public ViewMap(Object view, boolean includeClassName) { this.includeClassName = includeClassName; this.view = view; this.unresolved = new LinkedHashMap<>(); this.resolved = new LinkedHashMap<>(); // find all the classes that should be checked for bean properties getViewClasses(view) .stream() // grab the list of bean property descriptors .map(ViewMap::getBeanPropertyDescriptors) // flatten the descriptors across all the classes .flatMap(Collection::stream) // exclude the getClass() method .filter((prop) -> !"class".equals(prop.getName())) // ensure the read (getter) method is present .filter((prop) -> prop.getReadMethod() != null) // load the properties into a map. .collect(Collectors.toMap( // key is the descriptor name PropertyDescriptor::getName, // value is a supplier of the read method's value. prop -> () -> invoke(prop.getReadMethod(), view), // merge function just keeps the original value (m1, m2) -> m1, // store them in the unresolved map () -> unresolved)); if (view instanceof ViewModel) { Object model = ((ViewModel) view).model; ClassFinder.findConcreteClasses(ViewModelOverlay.class).stream() .map(c -> TypeDefinition.getInstance(c).newInstance().create(model)) .filter(Objects::nonNull) .forEach(overlay -> unresolved.putAll(overlay)); } if (includeClassName) { resolved.put("class", view.getClass().getName()); } } /** * @return the backing view object for this map. */ public Object toView() { return view; } @Override public int size() { resolver.ensure(); return resolved.size(); } @Override public boolean isEmpty() { resolver.ensure(); return resolved.isEmpty(); } @Override public boolean containsKey(Object key) { resolver.ensure(); return resolved.containsKey(key); } @Override public boolean containsValue(Object value) { resolver.ensure(); return resolved.containsValue(value); } @Override public Object get(Object key) { if (key instanceof String) { if (resolved.containsKey(key)) { return resolved.get(key); } else { Supplier<Object> supplier = unresolved.remove(key); if (supplier != null) { ThreadLocalStack<String> fieldStack = ImageSize.getFieldStack(); String keyString = (String) key; Object value; fieldStack.push(keyString); try { value = supplier.get(); } finally { fieldStack.pop(); } value = convertValue(keyString, value); if (value != null) { resolved.put((String) key, value); } return value; } else { return null; } } } else { return null; } } @Override public Object put(String key, Object value) { throw new UnsupportedOperationException(); } @Override public Object remove(Object key) { throw new UnsupportedOperationException(); } @Override public void putAll(Map<? extends String, ?> map) { throw new UnsupportedOperationException(); } @Override public void clear() { throw new UnsupportedOperationException(); } @Override public Set<String> keySet() { resolver.ensure(); return Collections.unmodifiableSet(resolved.keySet()); } @Override public Collection<Object> values() { resolver.ensure(); return Collections.unmodifiableCollection(resolved.values()); } @Override public Set<Entry<String, Object>> entrySet() { resolver.ensure(); return Collections.unmodifiableSet(resolved.entrySet()); } @Override public String toString() { return "{" + StringUtils.join(entrySet() .stream() .map((e) -> e.getKey() + "=" + e.getValue()) .collect(Collectors.toList()), ", ") + "}"; } /* * Converts a value to a Json Map friendly value. Only supports String, * Boolean, Number, Collection, and simple String key (non-State) based Maps. */ private Object convertValue(String key, Object value) { if (value instanceof CharSequence) { return value; } else if (value instanceof Boolean) { return value; } else if (value instanceof Number) { return value; } else if (value instanceof Collection) { List<Object> immutableViewList = new ArrayList<>(); for (Object item : (Iterable<?>) value) { immutableViewList.add(convertValue(key, item)); } return immutableViewList; } else if (value instanceof ViewMap) { // pass through return value; } else if (value instanceof Map) { Map<String, Object> convertedMap = new LinkedHashMap<>(); for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) { Object entryKey = entry.getKey(); Object entryValue = entry.getValue(); if (entryKey instanceof String) { convertedMap.put((String) entryKey, convertValue((String) entryKey, entryValue)); } } return convertedMap; } else if (value != null && !getViewClasses(value).isEmpty()) { return new ViewMap(value, includeClassName); } else if (value != null) { LOGGER.warn("Unsupported type [{}] returned from [{}#{}].", new Object[] { value.getClass().getName(), view.getClass().getName(), key }); } return null; } private static Object invoke(Method method, Object view) { try { method.setAccessible(true); return method.invoke(view); } catch (IllegalAccessException | InvocationTargetException e) { String message = "Failed to invoke method: " + method; Throwable cause = e.getCause(); cause = cause != null ? cause : e; ViewResponse response = ViewResponse.findInExceptionChain(cause); if (response != null) { throw response; } LOGGER.error(message, cause); if (Settings.isProduction()) { return null; } else { throw new RuntimeException(message, cause); } } } // Gets a list of all the classes that are implemented by the view objects // and are annotated with @ViewInterface. private static List<Class<?>> getViewClasses(Object view) { // find all the classes that could contain annotations return ViewUtils.getAnnotatableClasses(view.getClass()) .stream() // that are annotated with @ViewInterface .filter(klass -> klass.isAnnotationPresent(ViewInterface.class)) // add to list .collect(Collectors.toList()); } // Gets the list of bean property descriptors for the specified class. private static List<PropertyDescriptor> getBeanPropertyDescriptors(Class<?> viewClass) { try { return Arrays.asList(Introspector.getBeanInfo(viewClass).getPropertyDescriptors()); } catch (IntrospectionException e) { LOGGER.warn("Failed to introspect bean info for view of type [" + viewClass.getClass().getName() + "]. Cause: " + e.getMessage()); return Collections.emptyList(); } } }