/**
* Copyright 2013 the original author or authors.
*
* 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.
**/
package io.neba.core.resourcemodels.mapping;
import io.neba.api.resourcemodels.AnnotatedFieldMapper;
import io.neba.api.resourcemodels.Optional;
import io.neba.core.resourcemodels.metadata.MappedFieldMetaData;
import io.neba.core.util.PrimitiveSupportingValueMap;
import io.neba.core.util.ReflectionUtil;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ValueMap;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.cglib.proxy.LazyLoader;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.util.*;
import static io.neba.core.resourcemodels.mapping.AnnotatedFieldMappers.AnnotationMapping;
import static io.neba.core.util.ReflectionUtil.instantiateCollectionType;
import static io.neba.core.util.StringUtil.append;
import static org.apache.commons.lang.StringUtils.isBlank;
import static org.springframework.util.ReflectionUtils.getField;
import static org.springframework.util.ReflectionUtils.setField;
/**
* Attempts to load the property or resource associated with each
* {@link io.neba.core.resourcemodels.metadata.MappedFieldMetaData mappable field} of a
* {@link io.neba.api.annotations.ResourceModel},
* {@link #convert(org.apache.sling.api.resource.Resource, Class) convert} it to the suitable field type
* and inject it into the corresponding field.
*
* @author Olaf Otto
*/
public class FieldValueMappingCallback {
private final Object model;
private final ValueMap properties;
private final Resource resource;
private final ConfigurableBeanFactory beanFactory;
private final AnnotatedFieldMappers annotatedFieldMappers;
/**
* @param model the model to be mapped. Must not be null.
* @param resource the source of property values for the model. Must not be null.
* @param factory must not be null.
* @param annotatedFieldMappers must not be null.
*/
public FieldValueMappingCallback(Object model, Resource resource, BeanFactory factory, AnnotatedFieldMappers annotatedFieldMappers) {
if (model == null) {
throw new IllegalArgumentException("Constructor parameter model must not be null.");
}
if (resource == null) {
throw new IllegalArgumentException("Constructor parameter resource must not be null.");
}
if (factory == null) {
throw new IllegalArgumentException("Constructor parameter factory must not be null.");
}
if (annotatedFieldMappers == null) {
throw new IllegalArgumentException("Method argument customFieldMappers must not be null.");
}
this.model = model;
this.properties = toValueMap(resource);
this.resource = resource;
this.beanFactory = factory instanceof ConfigurableBeanFactory ? (ConfigurableBeanFactory) factory : null;
this.annotatedFieldMappers = annotatedFieldMappers;
}
/**
* Invoked for each {@link io.neba.core.resourcemodels.metadata.ResourceModelMetaData#getMappableFields() mappable field}
* of a {@link io.neba.api.annotations.ResourceModel} to map the {@link MappedFieldMetaData#getField() corresponding field's}
* value from the resource provided to the {@link #FieldValueMappingCallback(Object, Resource, BeanFactory, AnnotatedFieldMappers) constructor}.
*
* @param metaData must not be <code>null</code>.
*/
public final void doWith(final MappedFieldMetaData metaData) {
if (metaData == null) {
throw new IllegalArgumentException("Method argument metaData must not be null.");
}
// Prepare the dynamic contextual data of this mapping
final FieldData fieldData = new FieldData(metaData, evaluateFieldPath(metaData));
Object value = null;
if (isMappable(fieldData)) {
// Explicit lazy loading: provide the lazy loading implementation, not not map anything.
if (metaData.isOptional()) {
setField(metaData.getField(), this.model, new OptionalFieldValue(fieldData, this));
return;
}
value = resolve(fieldData);
}
value = postProcessResolvedValue(fieldData, value);
if (value != null) {
setField(metaData.getField(), this.model, value);
}
}
/**
* Resumes a mapping temporarily suspended by an {@link Optional} field, i.e.
* effectively loads a lazy-loaded field value.
*
* @param fieldData must not be <code>null</code>.
* @return the resolved value, or <code>null</code>.
*/
private Object resumeMapping(FieldData fieldData) {
return postProcessResolvedValue(fieldData, resolve(fieldData));
}
/**
* Implements the field NEBA contracts (such as non-null collection-typed fields) and applies
* {@link AnnotatedFieldMapper custom field mappers}.
*
* @param fieldData must not be <code>null</code>.
* @param value can be <code>null</code>.
* @return the post-processed value, can be <code>null</code>.
*/
private Object postProcessResolvedValue(FieldData fieldData, Object value) {
// For convenience, NEBA guarantees that any mappable collection-typed field is never <code>null</code> but rather
// an empty collection, in case no non-<code>null</code> default value was provided and field is not Optional.
boolean preventNullCollection =
value == null &&
!fieldData.metaData.isOptional() &&
fieldData.metaData.isInstantiableCollectionType() &&
getField(fieldData.metaData.getField(), this.model) == null;
@SuppressWarnings("unchecked")
Object defaultValue = preventNullCollection ? instantiateCollectionType((Class<Collection>) fieldData.metaData.getType()) : null;
// Provide the custom mappers with the default value in case of empty collections for convenience
value = applyCustomMappings(fieldData, value == null ? defaultValue : value);
return value == null ? defaultValue : value;
}
/**
* Applies all {@link io.neba.api.resourcemodels.AnnotatedFieldMapper registered field mappers}
* to the provided value and returns the result.
*/
@SuppressWarnings("unchecked")
private Object applyCustomMappings(FieldData fieldData, final Object value) {
Object result = value;
for (final AnnotationMapping mapping : this.annotatedFieldMappers.get(fieldData.metaData)) {
result = mapping.getMapper().map(new OngoingFieldMapping(this.model, result, mapping, fieldData, this.resource, this.properties));
}
return result;
}
/**
* Resolves the field's value with regard to the {@link io.neba.core.resourcemodels.metadata.MappedFieldMetaData}
* of the field.
*/
private Object resolve(FieldData fieldData) {
Object value;
if (fieldData.metaData.isThisReference()) {
// The field is a @This reference
value = convertThisResourceToFieldType(fieldData);
} else if (fieldData.metaData.isChildrenAnnotationPresent()) {
// The field is a collection of @Children
value = resolveChildren(fieldData);
} else if (fieldData.metaData.isReference()) {
// The field is a @Reference
value = resolveReferenceValueOfField(fieldData);
} else if (fieldData.metaData.isPropertyType()) {
// The field points to a property of the resource
value = resolvePropertyTypedValue(fieldData);
} else {
// The field points to another resource
value = resolveResource(fieldData.path, fieldData.metaData.getType());
}
return value;
}
private Object convertThisResourceToFieldType(FieldData field) {
return convert(this.resource, field.metaData.getType());
}
/**
* <p>
* Resolves the {@link org.apache.sling.api.resource.Resource#listChildren() children}
* of a parent resource. This parent resource may be either:
* </p>
* <ul>
* <li>The current resource, if no {@link io.neba.api.annotations.Path} or
* {@link io.neba.api.annotations.Reference} annotations are present</li>
* <li>A resource designated by an absolute or relative path of a path annotation</li>
* <li>A referenced resource (which may be combined with a {@link io.neba.api.annotations.Path} annotation)</li>
* </ul>
*/
private Collection<?> resolveChildren(FieldData field) {
Resource parent = null;
Collection<?> children = null;
if (field.metaData.isReference()) {
String referencedPath = resolvePropertyTypedValue(field, String.class);
if (!isBlank(referencedPath)) {
parent = resolveResource(referencedPath, Resource.class);
}
} else if (field.metaData.isPathAnnotationPresent()) {
parent = resolveResource(field.path, Resource.class);
} else {
parent = this.resource;
}
if (parent != null) {
children = createCollectionOfChildren(field, parent);
}
return children;
}
/**
* If the field is already {@link io.neba.core.resourcemodels.metadata.MappedFieldMetaData#isOptional() optional},
* {@link #loadChildren(io.neba.core.resourcemodels.mapping.FieldValueMappingCallback.FieldData, org.apache.sling.api.resource.Resource)} directly loads}
* the children. Otherwise, provides a lazy loading collection.
*
* @return never <code>null</code> but rather an empty collection.
*/
private Collection<?> createCollectionOfChildren(final FieldData field, final Resource parent) {
if (field.metaData.isOptional()) {
// The field was already lazy-loaded - do not lazy-load again.
return loadChildren(field, parent);
}
// Create a lazy loading proxy for the collection
return (Collection<?>) field.metaData.getCollectionProxyFactory().newInstance(new LazyChildrenLoader(field, parent, this));
}
/**
* Loads all children of the given resource, {@link #convert(org.apache.sling.api.resource.Resource, Class) adapts}
* them if required, and adds them to a newly create collection compatible to the
* {@link io.neba.core.resourcemodels.metadata.MappedFieldMetaData#getType() field type}, if the adaptation result is not
* <code>null</code>.
*
* @return never null but rather an empty collection.
*/
@SuppressWarnings("unchecked")
private Collection<Object> loadChildren(FieldData field, Resource parent) {
final Class<Collection<Object>> collectionType = (Class<Collection<Object>>) field.metaData.getType();
final Collection<Object> values = instantiateCollectionType(collectionType);
final Class<?> targetType = field.metaData.getTypeParameter();
Iterator<Resource> children = parent.listChildren();
while (children.hasNext()) {
Resource child = children.next();
if (field.metaData.isResolveBelowEveryChildPathPresentOnChildren()) {
// @Children(resolveBelowEveryChild = "...")
child = child.getChild(field.metaData.getResolveBelowEveryChildPathOnChildren());
if (child == null) {
continue;
}
}
Object adapted = convert(child, targetType);
if (adapted != null) {
values.add(adapted);
}
}
return values;
}
/**
* Resolves the String path(s) stored in the current field to the respective resources and adapts
* them, if necessary. May provide a single adapted value or a collection of references,
* with regard to the field's meta data.
*/
private Object resolveReferenceValueOfField(FieldData field) {
Object value = null;
// Regardless of its path, the field references another resource.
// fetch the field value (the path(s) to the referenced resource(s)) and resolve these resources.
if (field.metaData.isCollectionType()) {
String[] referencedResourcePaths = resolvePropertyTypedValue(field, String[].class);
if (referencedResourcePaths != null) {
value = createCollectionOfReferences(field, referencedResourcePaths);
}
} else {
String referencedResourcePath = resolvePropertyTypedValue(field, String.class);
if (referencedResourcePath != null) {
if (field.metaData.isAppendPathPresentOnReference()) {
referencedResourcePath += field.metaData.getAppendPathOnReference();
}
value = resolveResource(referencedResourcePath, field.metaData.getType());
}
}
return value;
}
/**
* If the field is already {@link io.neba.core.resourcemodels.metadata.MappedFieldMetaData#isOptional() optional},
* {@link #loadReferences(io.neba.core.resourcemodels.mapping.FieldValueMappingCallback.FieldData, String[]) directly loads}
* the references. Otherwise, provides a lazy loading collection.
*
* @param paths relative or absolute paths to resources.
* @return never <code>null</code> but rather an empty collection.
*/
private Collection<Object> createCollectionOfReferences(final FieldData field, final String[] paths) {
if (field.metaData.isOptional()) {
// The field was already lazy-loaded - no not lazy-load again.
return loadReferences(field, paths);
}
// Create a lazy loading proxy for the collection
@SuppressWarnings("unchecked")
Collection<Object> result = (Collection<Object>) field.metaData.getCollectionProxyFactory().newInstance(new LazyReferencesLoader(field, paths, this));
return result;
}
/**
* Resolves and converts all references of the given array of paths. <br />
* Afterwards, the resulting instances are stored in a {@link java.util.Collection} compatible to the
* collection type of the given {@link io.neba.core.resourcemodels.metadata.MappedFieldMetaData#getType()}.
*
* @param paths relative or absolute paths to resources.
* @return never <code>null</code> but rather an empty collection.
*/
@SuppressWarnings({"unchecked"})
private Collection<Object> loadReferences(FieldData field, String[] paths) {
final Class<Collection<Object>> collectionType = (Class<Collection<Object>>) field.metaData.getType();
final Collection<Object> values = instantiateCollectionType(collectionType, paths.length);
String[] resourcePaths = paths;
if (field.metaData.isAppendPathPresentOnReference()) {
// @Reference(append = "...")
resourcePaths = append(field.metaData.getAppendPathOnReference(), paths);
}
final Class<?> componentClass = field.metaData.getTypeParameter();
for (String path : resourcePaths) {
Object element = resolveResource(path, componentClass);
if (element != null) {
values.add(element);
}
}
return values;
}
/**
* Resolves a field's value via the {@link FieldValueMappingCallback.FieldData#path field path}.
* Supports conversion from array properties (such as String[]) to the desired collection type of the field.
*
* @return the resolved value, or <code>null</code>.
*/
private Object resolvePropertyTypedValue(FieldData field) {
Object value;
if (field.metaData.isInstantiableCollectionType()) {
value = getArrayPropertyAsCollection(field);
} else {
value = resolvePropertyTypedValue(field, field.metaData.getType());
}
return value;
}
/**
* Resolves a field's value using the field's {@link FieldValueMappingCallback.FieldData#path}. If the
* resource does not have any properties, the field path is absolute
* (see {@link #isMappable(io.neba.core.resourcemodels.mapping.FieldValueMappingCallback.FieldData)}),
* in which case the property is resolved via the resource resolver, i.e. the path is an absolute reference
* to the property of another resource.
* <br />
* Ignores all {@link FieldValueMappingCallback.FieldData#metaData meta data}
* except for the field path as the desired return type is explicitly specified.
*
* @return the resolved value, or <code>null</code>.
*/
private <T> T resolvePropertyTypedValue(FieldData field, Class<T> propertyType) {
if (field.isAbsolute() || field.isRelative()) {
return resolvePropertyTypedValueFromForeignResource(field, propertyType);
}
if (this.properties == null) {
throw new IllegalStateException("Tried to map the property " + field +
" even though the resource has no properties.");
}
return this.properties.get(field.path, propertyType);
}
/**
* Uses the current resource's {@link org.apache.sling.api.resource.ResourceResolver}
* to obtain the resource with the given path.
* The path can be absolute or relative to the current resource.
* {@link #convert(org.apache.sling.api.resource.Resource, Class) Converts} the resolved resource
* to the given field type if necessary.
*
* @return the resolved and converted resource, or <code>null</code>.
*/
private <T> T resolveResource(final String resourcePath, final Class<T> targetType) {
Resource absoluteResource = this.resource.getResourceResolver().getResource(this.resource, resourcePath);
return convert(absoluteResource, targetType);
}
/**
* Resolves a property via a property {@link Resource}. This is used to retrieve relative or absolute references to
* the properties of resources other than the current resource. Such references cannot be reliably retrieved from the current
* resource's {@link ValueMap} as the value map may be <code>null</code> and does not support access to properties from parent resources.
*
* @return the resolved value, or <code>null</code>.
*/
private <T> T resolvePropertyTypedValueFromForeignResource(FieldData field, Class<T> propertyType) {
Resource property = this.resource.getResourceResolver().getResource(this.resource, field.path);
if (property == null) {
return null;
}
// Only adaptation to String-types is supported by the property resource
if (propertyType == String.class || propertyType == String[].class) {
return property.adaptTo(propertyType);
}
// Obtain the ValueMap representation of the parent containing the property to use property conversion
Resource parent = property.getParent();
if (parent == null) {
return null;
}
ValueMap properties = parent.adaptTo(ValueMap.class);
if (properties == null) {
return null;
}
return new PrimitiveSupportingValueMap(properties).get(property.getName(), propertyType);
}
/**
* The fieldType is a collection. However, the component type of
* the collection is a property type, e.g. List<String>. We must
* fetch the property with the array-type of the component type (e.g. String[])
* and add the values to a new instance of Collection<T>.
*
* @return a collection of the resolved values, or <code>null</code> if no value could be resolved.
*/
private Collection<?> getArrayPropertyAsCollection(FieldData field) {
Class<?> arrayType = field.metaData.getArrayTypeOfTypeParameter();
Object[] elements = (Object[]) resolvePropertyTypedValue(field, arrayType);
if (elements != null) {
@SuppressWarnings("unchecked")
Collection<Object> collection = ReflectionUtil.instantiateCollectionType((Class<Collection<Object>>) field.metaData.getType());
Collections.addAll(collection, elements);
return collection;
}
return null;
}
/**
* Evaluates the {@link io.neba.core.resourcemodels.metadata.MappedFieldMetaData#isPathExpressionPresent() path expression}
* of the field (if any) using the {@link #beanFactory bean factory} of the models source bundle.
*/
private String evaluateFieldPath(MappedFieldMetaData fieldMetaData) {
String path = fieldMetaData.getPath();
if (fieldMetaData.isPathExpressionPresent()) {
path = evaluatePathExpression(path);
}
return path;
}
/**
* Delegates the evaluation of expressions such as <code>/content/site/${language}/subpage</code>
* to the bean factory.
*
* @see ConfigurableBeanFactory#resolveEmbeddedValue(String)
*/
private String evaluatePathExpression(String pathWithExpression) {
String path = pathWithExpression;
if (this.beanFactory != null) {
String evaluatedPath = this.beanFactory.resolveEmbeddedValue(pathWithExpression);
if (!isBlank(evaluatedPath)) {
path = evaluatedPath;
}
}
return path;
}
/**
* Determines whether a given field's value
* can be mapped from either the current resource properties
* or another (e.g. referenced) resource.
*/
private boolean isMappable(FieldData field) {
return this.properties != null ||
field.metaData.isThisReference() ||
field.isReferenceToOtherResource();
}
/**
* Provides the properties of the resource as a {@link PrimitiveSupportingValueMap}.
*
* @param resource must not be <code>null</code>.
* @return the value map, or <code>null</code> if the resource has no properties,
* e.g. if it is synthetic.
*/
private static ValueMap toValueMap(Resource resource) {
ValueMap propertyMap = resource.adaptTo(ValueMap.class);
if (propertyMap != null) {
propertyMap = new PrimitiveSupportingValueMap(propertyMap);
}
return propertyMap;
}
/**
* Converts the given {@link Resource} to the given target type
* by either {@link import org.apache.sling.api.adapter.Adaptable#adaptTo(Class) adapting}
* the resource to the target type or by returning the resource itself if the target type
* is {@link Resource}.
*/
@SuppressWarnings("unchecked")
private static <T> T convert(final Resource resource, final Class<T> targetType) {
if (resource == null) {
return null;
}
if (targetType.isAssignableFrom(resource.getClass())) {
return (T) resource;
}
return resource.adaptTo(targetType);
}
/**
* Represents the the contextual data of a resource model field during
* {@link FieldValueMappingCallback#doWith(io.neba.core.resourcemodels.metadata.MappedFieldMetaData) mapping}.
*/
private static final class FieldData {
private final MappedFieldMetaData metaData;
private final String path;
private final boolean isAbsolute;
private final boolean isRelative;
private FieldData(MappedFieldMetaData metaData, String path) {
this.metaData = metaData;
this.path = path;
this.isAbsolute = !path.isEmpty() && path.charAt(0) == '/';
this.isRelative = !this.isAbsolute && path.indexOf('/') != -1;
}
private boolean isAbsolute() {
return this.isAbsolute;
}
private boolean isRelative() {
return isRelative;
}
/**
* <p>
* Determines whether the mappedFieldMetaData represents a reference to another resource.
* This is the case IFF:
* </p>
*
* <ul>
* <li>it has a type that {@link MappedFieldMetaData#isPropertyType() can only be a property} or</li>
* <li>it it is annotated with an absolute path or</li>
* <li>it it is annotated with a relative path</li>
* </ul>.
*/
private boolean isReferenceToOtherResource() {
return !this.metaData.isPropertyType() || isAbsolute() || isRelative();
}
}
/**
* Implements explicit lazy-loading via {@link io.neba.api.resourcemodels.Optional}. Leverages the internal
* {@link #resolve(io.neba.core.resourcemodels.mapping.FieldValueMappingCallback.FieldData)}
* method and {@link io.neba.core.resourcemodels.mapping.FieldValueMappingCallback.FieldData} for this purpose.
*
* @author Olaf Otto
*/
private static class OptionalFieldValue implements Optional<Object> {
private static final Object NULL = new Object();
private final FieldData fieldData;
private final FieldValueMappingCallback callback;
private Object value = NULL;
OptionalFieldValue(FieldData fieldData, FieldValueMappingCallback callback) {
this.fieldData = fieldData;
this.callback = callback;
}
/**
* {@inheritDoc}
*/
@Override
public Object get() {
Object o = load();
if (o == null) {
throw new NoSuchElementException("The value of " + this.fieldData.metaData.getField() + " resolved to null.");
}
return o;
}
/**
* {@inheritDoc}
*/
@Override
public Object orElse(Object defaultValue) {
Object o = load();
return o == null ? defaultValue : o;
}
/**
* {@inheritDoc}
*/
@Override
public boolean isPresent() {
return orElse(null) != null;
}
/**
* The semantics of the value holder must adhere to the semantics of a non-lazy-loaded field value:
* The value is loaded exactly once, subsequent or concurrent access to the field value means accessing the
* same value. Thus, the value is retained and this method is thread-safe.
*/
private synchronized Object load() {
if (this.value == NULL) {
this.value = this.callback.resumeMapping(this.fieldData);
}
return this.value;
}
}
/**
* Lazy-loads collections of children.
*
* @see #createCollectionOfChildren(io.neba.core.resourcemodels.mapping.FieldValueMappingCallback.FieldData, org.apache.sling.api.resource.Resource)
* @author Olaf Otto
*/
private static class LazyChildrenLoader implements LazyLoader {
private final FieldData field;
private final Resource resource;
private final FieldValueMappingCallback mapper;
LazyChildrenLoader(FieldData field, Resource resource, FieldValueMappingCallback callback) {
this.field = field;
this.resource = resource;
this.mapper = callback;
}
@Override
public Object loadObject() throws Exception {
return this.mapper.loadChildren(field, resource);
}
}
/**
* Lazy-loads collections of references.
*
* @see #createCollectionOfReferences(io.neba.core.resourcemodels.mapping.FieldValueMappingCallback.FieldData, String[])
* @author Olaf Otto
*/
private static class LazyReferencesLoader implements LazyLoader {
private final FieldData field;
private final String[] paths;
private final FieldValueMappingCallback callback;
LazyReferencesLoader(FieldData field, String[] paths, FieldValueMappingCallback callback) {
this.field = field;
this.paths = paths;
this.callback = callback;
}
@Override
public Object loadObject() throws Exception {
return this.callback.loadReferences(field, paths);
}
}
/**
* @author Olaf Otto
*/
private static class OngoingFieldMapping implements AnnotatedFieldMapper.OngoingMapping {
private final Object resolvedValue;
private final AnnotationMapping mapping;
private final FieldData fieldData;
private final Object model;
private final Resource resource;
private final ValueMap properties;
private final MappedFieldMetaData metaData;
OngoingFieldMapping(Object model,
Object resolvedValue,
AnnotationMapping mapping,
FieldData fieldData,
Resource resource,
ValueMap properties) {
this.model = model;
this.resolvedValue = resolvedValue;
this.mapping = mapping;
this.metaData = fieldData.metaData;
this.fieldData = fieldData;
this.resource = resource;
this.properties = properties;
}
@Override
public Object getResolvedValue() {
return resolvedValue;
}
@Override
public Object getAnnotation() {
return mapping.getAnnotation();
}
@Override
public Object getModel() {
return model;
}
@Override
public Field getField() {
return metaData.getField();
}
@Override
public Map<Class<? extends Annotation>, Annotation> getAnnotationsOfField() {
return metaData.getAnnotations().getAnnotations();
}
@Override
public Class<?> getFieldType() {
return metaData.getType();
}
@Override
public Class<?> getFieldTypeParameter() {
return this.metaData.getTypeParameter();
}
@Override
public String getRepositoryPath() {
return fieldData.path;
}
@Override
public Resource getResource() {
return resource;
}
@Override
public ValueMap getProperties() {
return properties;
}
}
}