ca.gc.aafc.dina.mapper.DinaMappingRegistry Maven / Gradle / Ivy
package ca.gc.aafc.dina.mapper;
import ca.gc.aafc.dina.dto.RelatedEntity;
import ca.gc.aafc.dina.repository.meta.JsonApiExternalRelation;
import io.crnk.core.resource.annotations.JsonApiId;
import io.crnk.core.resource.annotations.JsonApiRelation;
import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;
import lombok.SneakyThrows;
import org.apache.commons.lang3.reflect.FieldUtils;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Registry to track information regarding a given resource class. Useful to obtain certain meta information
* regarding the domain of resource.
*/
public class DinaMappingRegistry {
// Tracks Attributes per class for bean mapping
@Getter
private final Map, Set> attributesPerClass;
// Tracks the mappable relations per class
private final Map, Set> mappableRelationsPerClass;
// Tracks external relation types per field name for external relations mapping
private final Map externalNameToTypeMap;
// Track Json Id field names for mapping
private final Map, String> jsonIdFieldNamePerClass;
// Track Field adapters per class
@Getter
private final Map, DinaFieldAdapterHandler>> fieldAdaptersPerClass;
/**
* Parsing a given resource graph requires the use of reflection. A DinaMappingRegistry should not be
* constructed in a repetitive manner where performance is needed.
*
* @param resourceClass - resource class to track
*/
public DinaMappingRegistry(@NonNull Class> resourceClass) {
Set> resources = parseGraph(resourceClass, new HashSet<>());
this.externalNameToTypeMap = parseExternalRelationNamesToType(resourceClass);
this.attributesPerClass = parseAttributesPerClass(resources);
this.mappableRelationsPerClass = parseMappableRelations(resources);
this.jsonIdFieldNamePerClass = parseJsonIds(resources);
this.fieldAdaptersPerClass = parseFieldAdapters(resources);
}
/**
* Returns a set of the mappable relations for a given class.
*
* @param cls - class with relations
* @return Returns a set of the mappable relations for a given class.
* @throws IllegalArgumentException if the class is not tracked by the registry
*/
public Set findMappableRelationsForClass(Class> cls) {
if (!this.mappableRelationsPerClass.containsKey(cls)) {
throw new IllegalArgumentException(cls.getSimpleName() + " is not tracked by the registry");
}
return this.mappableRelationsPerClass.get(cls);
}
/**
* Returns the set of external relation field names tracked by the registry.
*
* @return set of external relation field names.
*/
public Set getExternalRelations() {
return this.externalNameToTypeMap.keySet();
}
/**
* Returns the {@link JsonApiExternalRelation} type of the given external relation field name if tracked by
* the registry.
*
* @param relationFieldName - field name of the external relation.
* @return type of the given external relation.
* @throws IllegalArgumentException if the relationFieldName is not tracked by the registry
*/
public String findExternalType(String relationFieldName) {
if (!this.externalNameToTypeMap.containsKey(relationFieldName)) {
throw new IllegalArgumentException(
"external relation with name: " + relationFieldName + " is not tracked by the registry");
}
return this.externalNameToTypeMap.get(relationFieldName);
}
/**
* Returns true if the relation with the given field name is external.
*
* @param relationFieldName - field name of the external relation.
* @return Returns true if the relation with the given field name is external.
*/
public boolean isRelationExternal(String relationFieldName) {
return this.externalNameToTypeMap.keySet().stream()
.anyMatch(relationFieldName::equalsIgnoreCase);
}
/**
* Returns the json id field name of a given class.
*
* @param cls - cls with json id field
* @return the json id field name of a given class.
* @throws IllegalArgumentException if the class is not tracked by the registry
*/
public String findJsonIdFieldName(Class> cls) {
if (!this.jsonIdFieldNamePerClass.containsKey(cls)) {
throw new IllegalArgumentException(cls.getSimpleName() + " is not tracked by the registry");
}
return this.jsonIdFieldNamePerClass.get(cls);
}
/**
* Returns the nested resource type from a given base resource type and attribute path. The deepest nested
* resource that can be resolved will be returned. The original resource is returned if a nested resource is
* not present in the attribute path, or the resources are not tracked by the registry.
*
* @param resource - base resource to traverse
* @param attributePath - attribute path to follow
* @return - the nested resource type from a given path.
*/
public Class> resolveNestedResourceFromPath(
@NonNull Class> resource,
@NonNull List attributePath
) {
Class> nested = resource;
for (String attribute : attributePath) {
Optional relation = this.findMappableRelationsForClass(nested).stream()
.filter(internalRelation -> internalRelation.getName().equalsIgnoreCase(attribute))
.findAny();
if (relation.isPresent()) {
nested = relation.get().getElementType();
} else {
break;
}
}
return nested;
}
private Set> parseGraph(Class> dto, Set> visited) {
if (visited.contains(dto)) {
return visited;
}
visited.add(dto);
for (Field field : FieldUtils.getFieldsListWithAnnotation(dto, JsonApiRelation.class)) {
if (isCollection(field.getType())) {
Class> genericType = getGenericType(field.getDeclaringClass(), field.getName());
parseGraph(genericType, visited);
} else {
parseGraph(field.getType(), visited);
}
}
return visited;
}
private Map, String> parseJsonIds(Set> resources) {
Map, String> map = new HashMap<>();
resources.forEach(dtoClass -> {
for (Field field : FieldUtils.getAllFieldsList(dtoClass)) {
if (field.isAnnotationPresent(JsonApiId.class)) {
map.put(dtoClass, field.getName());
break;
}
}
});
return Map.copyOf(map);
}
private Map, Set> parseAttributesPerClass(Set> resources) {
Map, Set> map = new HashMap<>();
resources.forEach(dtoClass -> {
RelatedEntity relatedEntity = dtoClass.getAnnotation(RelatedEntity.class);
if (relatedEntity != null) {
Set fieldsToInclude = FieldUtils.getAllFieldsList(dtoClass).stream()
.filter(DinaMappingRegistry::isFieldMappable)
.map(Field::getName)
.collect(Collectors.toSet());
map.put(dtoClass, Set.copyOf(fieldsToInclude));
map.put(relatedEntity.value(), Set.copyOf(fieldsToInclude));
}
});
return Map.copyOf(map);
}
private Map, Set> parseMappableRelations(Set> resources) {
Map, Set> map = new HashMap<>();
resources.forEach(dtoClass -> {
RelatedEntity relatedEntity = dtoClass.getAnnotation(RelatedEntity.class);
if (relatedEntity != null) {
Set mappableRelations = FieldUtils
.getFieldsListWithAnnotation(dtoClass, JsonApiRelation.class).stream()
.filter(field -> isRelationMappable(dtoClass, relatedEntity.value(), field))
.map(DinaMappingRegistry::mapToInternalRelation)
.collect(Collectors.toSet());
Set entityRelations = mappableRelations.stream().map(
ir -> InternalRelation.builder().name(ir.getName()).isCollection(ir.isCollection())
.elementType(ir.getElementType().getAnnotation(RelatedEntity.class).value()).build()
).collect(Collectors.toSet());
map.put(dtoClass, Set.copyOf(mappableRelations));
map.put(relatedEntity.value(), Set.copyOf(entityRelations));
}
});
return Map.copyOf(map);
}
private static InternalRelation mapToInternalRelation(Field field) {
if (isCollection(field.getType())) {
Class> genericType = getGenericType(field.getDeclaringClass(), field.getName());
return InternalRelation.builder()
.name(field.getName()).isCollection(true).elementType(genericType).build();
} else {
return InternalRelation.builder()
.name(field.getName()).isCollection(false).elementType(field.getType()).build();
}
}
/**
* Returns a map of external relation field names to their JsonApiExternalRelation.type for a given class.
*
* @param resourceClass - a given class with external relations.
* @return a map of external relation field names to their JsonApiExternalRelation.type
*/
private static Map parseExternalRelationNamesToType(Class> resourceClass) {
return Map.copyOf(
FieldUtils.getFieldsListWithAnnotation(resourceClass, JsonApiExternalRelation.class)
.stream().collect(Collectors.toMap(
Field::getName,
field -> field.getAnnotation(JsonApiExternalRelation.class).type())));
}
private Map, DinaFieldAdapterHandler>> parseFieldAdapters(Set> resources) {
Map, DinaFieldAdapterHandler>> adapterPerClass = new HashMap<>();
for (Class> dto : resources) {
RelatedEntity annotation = dto.getAnnotation(RelatedEntity.class);
if (annotation != null && dto.isAnnotationPresent(CustomFieldAdapter.class)) {
Class> relatedEntity = annotation.value();
DinaFieldAdapterHandler> handler = new DinaFieldAdapterHandler<>(dto);
adapterPerClass.put(dto, handler);
adapterPerClass.put(relatedEntity, handler);
}
}
return Map.copyOf(adapterPerClass);
}
/**
* Returns true if the dina repo should map the given field. currently that means if the field is not
* generated (Marked with {@link IgnoreDinaMapping}), final, or is a {@link JsonApiRelation}.
*
* @param field - field to evaluate
* @return - true if the dina repo should not map the given field
*/
private static boolean isFieldMappable(Field field) {
return !field.isAnnotationPresent(IgnoreDinaMapping.class) &&
!field.isAnnotationPresent(JsonApiRelation.class)
&& !Modifier.isFinal(field.getModifiers())
&& !field.isSynthetic();
}
/**
* Returns true if the dina repo should map the given relation. A relation should be mapped if it is not
* external, and present in the given dto and entity classes.
*
* @param dto - resource class of the relation
* @param entity - entity class of the relation
* @param dtoRelationField - relation field to map
* @return true if the dina repo should map the given relation.
*/
private static boolean isRelationMappable(Class> dto, Class> entity, Field dtoRelationField) {
return !dtoRelationField.isAnnotationPresent(IgnoreDinaMapping.class) &&
!dtoRelationField.isAnnotationPresent(JsonApiExternalRelation.class) &&
Stream.of(entity.getDeclaredFields())
.map(Field::getName)
.anyMatch(dtoRelationField.getName()::equalsIgnoreCase) &&
Stream.of(dto.getDeclaredFields())
.map(Field::getName)
.anyMatch(dtoRelationField.getName()::equalsIgnoreCase);
}
/**
* Returns the class of the parameterized type at the first position of a given class's given field.
*
* given class is assumed to be a {@link ParameterizedType}
*
* @param source given class
* @param fieldName field name of the given class to parse
* @return class of the paramterized type at the first position
*/
@SneakyThrows
private static Class> getGenericType(Class> source, String fieldName) {
ParameterizedType genericType = (ParameterizedType) source
.getDeclaredField(fieldName)
.getGenericType();
return (Class>) genericType.getActualTypeArguments()[0];
}
/**
* Returns true if the given class is a collection
*
* @param clazz - class to check
* @return true if the given class is a collection
*/
private static boolean isCollection(Class> clazz) {
return Collection.class.isAssignableFrom(clazz);
}
/**
* Internal Relation Representing a field of class to be mapped
*/
@Builder
@Getter
public static class InternalRelation {
private final String name;
private final Class> elementType;
private final boolean isCollection;
}
}