/*
* #%L
* FlatPack serialization code
* %%
* Copyright (C) 2012 Perka Inc.
* %%
* 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 com.getperka.flatpack.ext;
import static com.getperka.flatpack.util.FlatPackCollections.identitySetForIteration;
import static com.getperka.flatpack.util.FlatPackCollections.listForAny;
import static com.getperka.flatpack.util.FlatPackCollections.mapForIteration;
import static com.getperka.flatpack.util.FlatPackCollections.mapForLookup;
import static com.getperka.flatpack.util.FlatPackCollections.sortedMapForIteration;
import static com.getperka.flatpack.util.FlatPackTypes.decapitalize;
import static com.getperka.flatpack.util.FlatPackTypes.erase;
import static com.getperka.flatpack.util.FlatPackTypes.getSingleParameterization;
import static com.getperka.flatpack.util.FlatPackTypes.hasAnnotationWithSimpleName;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Type;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Provider;
import javax.inject.Singleton;
import org.slf4j.Logger;
import com.getperka.flatpack.EntityMetadata;
import com.getperka.flatpack.HasUuid;
import com.getperka.flatpack.JsonProperty;
import com.getperka.flatpack.JsonTypeName;
import com.getperka.flatpack.PersistenceMapper;
import com.getperka.flatpack.SparseCollection;
import com.getperka.flatpack.codexes.DynamicCodex;
import com.getperka.flatpack.inject.AllTypes;
import com.getperka.flatpack.inject.FlatPackLogger;
import com.getperka.flatpack.security.SecurityPolicy;
import com.getperka.flatpack.security.SecurityTarget;
import com.getperka.flatpack.util.FlatPackCollections;
import com.getperka.flatpack.util.FlatPackTypes;
import com.google.inject.TypeLiteral;
/**
* Provides access to typesystem information and vends helper objects.
* <p>
* Instances of TypeContext are thread-safe and intended to be long-lived.
*/
@Singleton
public class TypeContext {
/**
* Extract the Java bean property name from a method. Note that this does not take any
* {@link JsonProperty} annotations into account, Getters and setters must be collated by the Java
* method names since setters aren't generally annotated.
*/
private static String beanPropertyName(Method m) {
String name = m.getName();
if (name.startsWith("is")) {
name = name.substring(2);
} else {
name = name.substring(3);
}
return decapitalize(name);
}
private static boolean isBoolean(Class<?> clazz) {
return boolean.class.equals(clazz) || Boolean.class.equals(clazz);
}
/**
* Returns {@code true} for:
* <ul>
* <li>public Foo getFoo()</li>
* <li>public boolean isFoo()</li>
* </ul>
* Ignores any private method or those annotated with {@link NoPack}.
*/
private static boolean isGetter(Method m) {
if (m.getParameterTypes().length != 0) {
return false;
}
String name = m.getName();
if (name.startsWith("get") && name.length() > 3 ||
name.startsWith("is") && name.length() > 2 && isBoolean(m.getReturnType())) {
if (m.isAnnotationPresent(NoPack.class)) {
return false;
}
if (!Modifier.isPrivate(m.getModifiers())) {
return true;
}
}
return false;
}
/**
* Analogous to {@link #isGetter(Method)}.
*/
private static boolean isSetter(Method m) {
if (m.getParameterTypes().length != 1) {
return false;
}
if (!m.getName().startsWith("set")) {
return false;
}
if (m.isAnnotationPresent(NoPack.class)) {
return false;
}
return !Modifier.isPrivate(m.getModifiers());
}
/**
* Used to instantiate instances of {@link Property}.
*/
@Inject
private Provider<Property.Builder> builderProvider;
@Inject
private CodexMapper codexMapper;
/**
* A map of flattened type representations to a codex capable of handling that type.
*/
private final Map<TypeLiteral<?>, Codex<?>> codexes = mapForLookup();
/**
* A DynamicCodex acts as a placeholder when type information can't be determined (which should be
* rare).
*/
@Inject
private DynamicCodex dynamicCodex;
private final Map<Class<? extends HasUuid>, EntityDescription> entitiesByClass = mapForIteration();
private final Map<String, EntityDescription> entitiesByName = mapForIteration();
/**
* State management to make {@link #describe(Class)} behave in the reentrant case.
*/
private Set<EntityDescription> isExtracting = identitySetForIteration();
@FlatPackLogger
@Inject
private Logger logger;
@Inject
private PersistenceMapper persistenceMapper;
@Inject
private SecurityPolicy securityPolicy;
@Inject
protected TypeContext() {}
/**
* Examine a class and return an {@link EntityDescription} with introspection data. Calls to this
* method are cached in the instance of {@link TypeContext}.
*/
public synchronized EntityDescription describe(Class<? extends HasUuid> clazz) {
if (clazz == null) {
throw new NullPointerException("clazz must be non-null");
}
EntityDescription toReturn = entitiesByClass.get(clazz);
if (toReturn != null) {
return toReturn;
}
boolean topCall = isExtracting.isEmpty();
// Create the type and add it to the map to short-circuit type-reference loops
toReturn = new EntityDescription();
isExtracting.add(toReturn);
entitiesByClass.put(clazz, toReturn);
// Extract the entity data
extractOneEntity(toReturn, clazz);
if (entitiesByName.put(toReturn.getTypeName(), toReturn) != null) {
logger.warn("Duplicate type name {}", clazz.getName());
}
if (topCall) {
finalizeEntityDescriptions();
}
return toReturn;
}
/**
* @deprecated Use {@link #describe(Class)} and {@link EntityDescription#getProperties()} instead.
*/
@Deprecated
public List<Property> extractProperties(Class<? extends HasUuid> clazz) {
return describe(clazz).getProperties();
}
/**
* Convenience method to provide generics alignment.
*/
@SuppressWarnings("unchecked")
public <T> Codex<T> getCodex(Class<? extends T> clazz) {
return (Codex<T>) getCodex((Type) clazz);
}
/**
* Return a Codex instance that can operate on the specified type.
*/
public synchronized Codex<?> getCodex(Type type) {
// Use a canonical representation of the type
TypeLiteral<?> lit = TypeLiteral.get(type);
Codex<?> toReturn = codexes.get(lit);
if (toReturn != null) {
return toReturn;
}
toReturn = codexMapper.getCodex(this, type);
if (toReturn == null) {
toReturn = dynamicCodex;
}
codexes.put(lit, toReturn);
return toReturn;
}
/**
* Finds an {@link EntityDescription} based on its simple type name.
*/
public EntityDescription getEntityDescription(String typeName) {
return entitiesByName.get(typeName);
}
public Collection<EntityDescription> getEntityDescriptions() {
return Collections.unmodifiableCollection(entitiesByClass.values());
}
/**
* @deprecated Use {@link #describe(Class)} and {@link EntityDescription#getTypeName()} instead.
*/
@Deprecated
public String getPayloadName(Class<? extends HasUuid> clazz) {
return describe(clazz).getTypeName();
}
@Inject
void inject(@AllTypes Collection<Class<?>> allTypes) {
if (allTypes.isEmpty()) {
logger.warn("No unpackable classes. Will not be able to deserialize entity payloads");
return;
}
EntityDescription dummy = new EntityDescription();
isExtracting.add(dummy);
for (Class<?> clazz : allTypes) {
if (!HasUuid.class.isAssignableFrom(clazz)) {
logger.warn("Ignoring type {} because it is not assignable to {}", clazz.getName(),
HasUuid.class.getSimpleName());
continue;
}
if (clazz.isInterface()) {
logger.warn("Ignoring interface {}", clazz.getName());
continue;
}
if (Modifier.isAbstract(clazz.getModifiers())) {
logger.warn("Ignoring abstract class {}", clazz.getName());
continue;
}
if (clazz.isAnonymousClass()) {
logger.warn("Ignoring anonymous class {}", clazz.getName());
continue;
}
describe(clazz.asSubclass(HasUuid.class));
}
// Used internally, should always be mapped
describe(EntityMetadata.class);
isExtracting.remove(dummy);
finalizeEntityDescriptions();
}
private void extractOneEntity(EntityDescription d, Class<? extends HasUuid> clazz) {
// Set identifying information before there's any chance of an escape
d.setEntityType(clazz);
d.setTypeName(getTypeName(clazz));
EntityDescription supertype;
List<Property> properties = listForAny();
if (!clazz.isInterface() && HasUuid.class.isAssignableFrom(clazz.getSuperclass())) {
// Start by collecting all supertype properties
supertype = describe(clazz.getSuperclass().asSubclass(HasUuid.class));
properties.addAll(supertype.getProperties());
} else {
supertype = null;
}
d.setPersistent(persistenceMapper.canPersist(clazz));
d.setProperties(Collections.unmodifiableList(properties));
d.setSupertype(supertype);
// Link implied properties after all other properties have been stubbed out
Map<Property.Builder, String> impliedPropertiesToLink = FlatPackCollections.mapForIteration();
// Examine each declared method on the type and assemble Property objects
Map<String, Property.Builder> builders = mapForIteration();
for (Method m : clazz.getDeclaredMethods()) {
if (isGetter(m)) {
String beanPropertyName = beanPropertyName(m);
Property.Builder builder = getBuilderForProperty(builders, beanPropertyName);
// Set the getter, and update the property name
builder.withGetter(m);
setJsonPropertyName(builder);
// Eagerly add the property to ensure implied properties work
if (!properties.contains(builder.peek())) {
properties.add(builder.peek());
}
// Look for SparseCollection, OneToMany or ManyToMany
builder.withDeepTraversalOnly(isDeepTraversalOnly(m));
/*
* Disable traversal of Implied / OneToMany properties unless requested. Also wire up the
* implication relationships between properties in the two models after all Properties have
* been constructed.
*/
String impliedPropertyName = getImpliedPropertyName(m);
if (impliedPropertyName != null) {
impliedPropertiesToLink.put(builder, impliedPropertyName);
}
} else if (isSetter(m)) {
Property.Builder builder = getBuilderForProperty(builders, beanPropertyName(m));
builder.withSetter(m);
setJsonPropertyName(builder);
}
}
// Wire the implied properties in the current class
for (Map.Entry<Property.Builder, String> entry : impliedPropertiesToLink.entrySet()) {
Property.Builder builder = entry.getKey();
String impliedPropertyName = entry.getValue();
Method getter = builder.peek().getGetter();
Type elementType = getSingleParameterization(getter.getGenericReturnType(), Collection.class);
if (elementType == null) {
logger.error("Method {}.{} defines a OneToMany / Implies relationship but the " +
"return type is not a Collection", clazz.getName(), getter.getName());
} else {
Class<? extends HasUuid> otherModel = erase(elementType).asSubclass(HasUuid.class);
List<Property> otherProperties = describe(otherModel).getProperties();
if (otherProperties != null) {
for (Property otherProperty : otherProperties) {
if (otherProperty.getName().equals(impliedPropertyName)) {
builder.withImpliedProperty(otherProperty);
otherProperty.setImpliedProperty(builder.peek());
break;
}
}
}
}
}
// Finish construction
for (Property.Builder builder : builders.values()) {
Property p = builder.build();
if (!properties.contains(p)) {
properties.add(p);
}
}
// Deduplicate by name, allowing subtype properties to replace supertype properties
Map<String, Property> propertiesByName = sortedMapForIteration();
for (Property p : properties) {
propertiesByName.put(p.getName(), p);
}
d.setProperties(Collections.unmodifiableList(listForAny(propertiesByName.values())));
logger.debug("Extracted type map: {} -> {}", clazz.getCanonicalName(), d.getTypeName());
}
/**
* Wire up security information. Because properties can refer to one another via group inheritance
* it is necessary to perform this calculation after the properties have been fully constructed.
* It's also necessary to allow for the security policy to have caused other types to be
* extracted, hence the loop.
*/
private void finalizeEntityDescriptions() {
while (!isExtracting.isEmpty()) {
// Copy out to prevent ConcurrentModificationException
List<EntityDescription> toFinish = listForAny(isExtracting);
isExtracting.clear();
for (EntityDescription d : toFinish) {
d.setGroupPermissions(securityPolicy.getPermissions(SecurityTarget.of(d.getEntityType())));
logger.debug("{} -> {}", d.getTypeName(), d.getGroupPermissions());
for (Property p : d.getProperties()) {
if (p.getGroupPermissions() == null) {
p.setGroupPermissions(securityPolicy.getPermissions(SecurityTarget.of(p)));
logger.debug("{}.{} -> {}", d.getTypeName(), p.getName(), p.getGroupPermissions());
}
}
}
}
}
/**
* Implements a get-or-create pattern.
*/
private Property.Builder getBuilderForProperty(Map<String, Property.Builder> builders,
String beanPropertyName) {
Property.Builder builder = builders.get(beanPropertyName);
if (builder == null) {
builder = builderProvider.get();
builders.put(beanPropertyName, builder);
}
return builder;
}
/**
* Extract the implied property name from an Implies or OneToMany annotation.
*/
private String getImpliedPropertyName(Method m) {
SparseCollection implies = m.getAnnotation(SparseCollection.class);
if (implies != null) {
// Treat the default value of an empty string as just a breakpoint, without implication
return implies.value().isEmpty() ? null : implies.value();
}
for (Annotation a : m.getAnnotations()) {
// Looking for a specific type to call a method on, so don't use hasAnnotation() method
if ("javax.persistence.OneToMany".equals(a.annotationType().getName())) {
try {
return (String) a.annotationType().getMethod("mappedBy").invoke(a);
} catch (Exception e) {
logger.error("Could not extract information from @OneToMany", e);
}
}
}
return null;
}
/**
* Returns the "type" name used for an entity type in the {@code data} section of the payload.
*/
private String getTypeName(Class<?> clazz) {
JsonTypeName override = clazz.getAnnotation(JsonTypeName.class);
if (override != null) {
return override.value();
}
return FlatPackTypes.decapitalize(clazz.getSimpleName());
}
private boolean isDeepTraversalOnly(Method m) {
return m.isAnnotationPresent(SparseCollection.class)
|| hasAnnotationWithSimpleName(m, "OneToMany");
}
/**
* Set the json property name of a Property, looking for annotations on the getter or setter.
*/
private void setJsonPropertyName(Property.Builder builder) {
Method m = builder.peek().getGetter();
if (m == null) {
m = builder.peek().getSetter();
}
JsonProperty override = m.getAnnotation(JsonProperty.class);
if (override != null) {
builder.withName(override.value());
} else {
builder.withName(beanPropertyName(m));
}
}
}