DATAREST-221 - Added support for projections.

This commit introduces support to access resources via projections, which means naming a dedicated set of properties of the entity to be exposed and being able to refer to that set through a request parameter.

## General usage

Projections are defined as interfaces that mimic the properties of the domain class to be exported:

@Projection(types = Customer.class, name = "summary")
interface Summary {
  String getFirstname();
  String getLastname();
  AddressSummary getAddress();
}

interface AddressSummary() {
  String getZipCode();
}

The projection interface can be annotated with @Projection to be auto-discovered. We scan all packages in which we find domain types to be exported for projection types and auto-register them. For manual registration, use RepositoryRestConfiguration.projectionDefinitionConfiguration().addProjection(…) and manually register them.

If a projection is registered for a given type, this will be indicated via a "projection" template variable in the URI pointing to resources with projections. The name of the variable can also be configured on ProjectionDefinitionConfiguration.

## Internals

The projection interfaces are consider bean property delegates by default. This means, that for the above interfaces we will lookup the firstname, lastname and address property of the projection target. In the case of address we re-project the result of the proxy target invocation with a sub-projection onto AddressSummary.

For more advanced use-cases you can annotate a method of the projection interface with @Value and use a SpEL expression to invoke further functionality and return that to be rendered:

interface MyProjection {

  @Value("#{@myBean.someMethod(target)}")
  SubProjection getValue();
}

This projection would call the someMethod(…) method on a Spring bean named myBean handing the proxy target to the method. The result will be projected in turn onto a type called SubProjection.

As the projection objects are exposed to Jackson as is, they can be annotated with Jackson annotations to further customize the representation.
This commit is contained in:
Oliver Gierke
2014-02-24 08:15:51 +01:00
parent faf9a48f30
commit af7e15b8e6
44 changed files with 1947 additions and 191 deletions

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.config;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation to tie a particular projection type to a source type. Used to find projection interfaces at startup time.
*
* @author Oliver Gierke
*/
@Inherited
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE })
public @interface Projection {
/**
* The type the projection type is bound to.
*
* @return
*/
Class<?>[] types();
/**
* The name of projection to refer to.
*
* @return
*/
String name() default "";
}

View File

@@ -0,0 +1,200 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.config;
import java.util.HashMap;
import java.util.Map;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.data.rest.core.projection.ProjectionDefinitions;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Wrapper class to register projection definitions for later lookup by name and source type.
*
* @author Oliver Gierke
*/
public class ProjectionDefinitionConfiguration implements ProjectionDefinitions {
private static final String PROJECTION_ANNOTATION_NOT_FOUND = "Projection annotation not found on %s! Either add the annotation or hand source type to the registration manually!";
private static final String DEFAULT_PROJECTION_PARAMETER_NAME = "projection";
private final Map<ProjectionDefinitionKey, Class<?>> projectionDefinitions;
private String parameterName = DEFAULT_PROJECTION_PARAMETER_NAME;
public ProjectionDefinitionConfiguration() {
this.projectionDefinitions = new HashMap<ProjectionDefinitionKey, Class<?>>();
}
/*
* (non-Javadoc)
* @see org.springframework.data.rest.core.projection.ProjectionDefinitions#getParameterName()
*/
public String getParameterName() {
return parameterName;
};
/**
* Configures the request parameter name to be used to accept the projection name to be returned.
*
* @param parameterName defaults to {@value ProjectionDefinitionConfiguration#DEFAULT_PROJECTION_PARAMETER_NAME}, will
* be set back to this default if {@literal null} or an empty value is configured.
*/
public void setParameterName(String parameterName) {
this.parameterName = StringUtils.hasText(parameterName) ? parameterName : DEFAULT_PROJECTION_PARAMETER_NAME;
}
/**
* Adds the given projection type to the configuration. The type has to be annotated with {@link Projection} for
* additional metadata.
*
* @param projectionType must not be {@literal null}.
* @return
* @see Projection
*/
public ProjectionDefinitionConfiguration addProjection(Class<?> projectionType) {
Assert.notNull(projectionType, "Projection type must not be null!");
Projection annotation = AnnotationUtils.findAnnotation(projectionType, Projection.class);
if (annotation == null) {
throw new IllegalArgumentException(String.format(PROJECTION_ANNOTATION_NOT_FOUND, projectionType));
}
String name = annotation.name();
Class<?>[] sourceTypes = annotation.types();
return StringUtils.hasText(name) ? addProjection(projectionType, name, sourceTypes) : addProjection(projectionType,
sourceTypes);
}
/**
* Adds a projection type for the given source types. The name of the projection will be defaulted to the
* uncapitalized simply class name.
*
* @param projectionType must not be {@literal null}.
* @param sourceTypes must not be {@literal null} or empty.
* @return
*/
public ProjectionDefinitionConfiguration addProjection(Class<?> projectionType, Class<?>... sourceTypes) {
Assert.notNull(projectionType, "Projection type must not be null!");
return addProjection(projectionType, StringUtils.uncapitalize(projectionType.getSimpleName()), sourceTypes);
}
/**
* Adds the given projection type for the given source types under the given name.
*
* @param projectionType must not be {@literal null}.
* @param name must not be {@literal null} or empty.
* @param sourceTypes must not be {@literal null} or empty.
* @return
*/
public ProjectionDefinitionConfiguration addProjection(Class<?> projectionType, String name, Class<?>... sourceTypes) {
Assert.notNull(projectionType, "Projection type must not be null!");
Assert.hasText(name, "Name must not be null or empty!");
Assert.notEmpty(sourceTypes, "Source types must not be null!");
for (Class<?> sourceType : sourceTypes) {
this.projectionDefinitions.put(new ProjectionDefinitionKey(sourceType, name), projectionType);
}
return this;
}
/*
* (non-Javadoc)
* @see org.springframework.data.rest.core.config.ProjectionDefinitions#getProjectionType(java.lang.Class, java.lang.String)
*/
@Override
public Class<?> getProjectionType(Class<?> sourceType, String name) {
return projectionDefinitions.get(new ProjectionDefinitionKey(sourceType, name));
}
/*
* (non-Javadoc)
* @see org.springframework.data.rest.core.projection.ProjectionDefinitions#hasProjectionFor(java.lang.Class)
*/
@Override
public boolean hasProjectionFor(Class<?> sourceType) {
for (ProjectionDefinitionKey key : projectionDefinitions.keySet()) {
if (key.sourceType.equals(sourceType)) {
return true;
}
}
return false;
}
/**
* Value object to define lookup keys for projections.
*
* @author Oliver Gierke
*/
static final class ProjectionDefinitionKey {
private final Class<?> sourceType;
private final String name;
/**
* Creates a new {@link ProjectionDefinitionKey} for the given source type and name;
*
* @param sourceType must not be {@literal null}.
* @param name must not be {@literal null} or empty.
*/
public ProjectionDefinitionKey(Class<?> sourceType, String name) {
Assert.notNull(sourceType, "Source type must not be null!");
Assert.hasText(name, "Name must not be null or empty!");
this.sourceType = sourceType;
this.name = name;
}
/*
* (non-Javadoc)
* @see java.lang.Object#equals(java.lang.Object)
*/
@Override
public boolean equals(Object obj) {
if (!(obj instanceof ProjectionDefinitionKey)) {
return false;
}
ProjectionDefinitionKey that = (ProjectionDefinitionKey) obj;
return this.name.equals(that.name) && this.sourceType.equals(that.sourceType);
}
/*
* (non-Javadoc)
* @see java.lang.Object#hashCode()
*/
@Override
public int hashCode() {
int result = 31;
result += name.hashCode();
result += sourceType.hashCode();
return result;
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2013 the original author or authors.
* Copyright 2012-2014 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.
@@ -35,7 +35,7 @@ public class RepositoryRestConfiguration {
private int defaultPageSize = 20;
private int maxPageSize = 1000;
private String pageParamName = "page";
private String limitParamName = "limit";
private String limitParamName = "size";
private String sortParamName = "sort";
private MediaType defaultMediaType = MediaTypes.HAL_JSON;
private boolean returnBodyOnCreate = false;
@@ -43,6 +43,25 @@ public class RepositoryRestConfiguration {
private List<Class<?>> exposeIdsFor = new ArrayList<Class<?>>();
private ResourceMappingConfiguration domainMappings = new ResourceMappingConfiguration();
private ResourceMappingConfiguration repoMappings = new ResourceMappingConfiguration();
private final ProjectionDefinitionConfiguration projectionConfiguration;
/**
* Creates a new default {@link RepositoryRestConfiguration}.
*/
public RepositoryRestConfiguration() {
this(new ProjectionDefinitionConfiguration());
}
/**
* Creates a new {@link RepositoryRestConfiguration} with the given {@link ProjectionDefinitionConfiguration}.
*
* @param projectionConfiguration must not be {@literal null}.
*/
public RepositoryRestConfiguration(ProjectionDefinitionConfiguration projectionConfiguration) {
Assert.notNull(projectionConfiguration, "ProjectionDefinitionConfiguration must not be null!");
this.projectionConfiguration = projectionConfiguration;
}
/**
* The base URI against which the exporter should calculate its links.
@@ -80,7 +99,7 @@ public class RepositoryRestConfiguration {
* @return {@literal this}
*/
public RepositoryRestConfiguration setDefaultPageSize(int defaultPageSize) {
Assert.isTrue((defaultPageSize > 0), "Page size must be greater than 0.");
Assert.isTrue(defaultPageSize > 0, "Page size must be greater than 0.");
this.defaultPageSize = defaultPageSize;
return this;
}
@@ -101,7 +120,7 @@ public class RepositoryRestConfiguration {
* @return {@literal this}
*/
public RepositoryRestConfiguration setMaxPageSize(int maxPageSize) {
Assert.isTrue((defaultPageSize > 0), "Maximum page size must be greater than 0.");
Assert.isTrue(defaultPageSize > 0, "Maximum page size must be greater than 0.");
this.maxPageSize = maxPageSize;
return this;
}
@@ -328,4 +347,13 @@ public class RepositoryRestConfiguration {
Collections.addAll(exposeIdsFor, domainTypes);
return this;
}
/**
* Returns the {@link ProjectionDefinitionConfiguration} to register addition projections.
*
* @return
*/
public ProjectionDefinitionConfiguration projectionConfiguration() {
return projectionConfiguration;
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.projection;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.util.Assert;
/**
* {@link MethodInterceptor} to delegate the invocation to a different {@link MethodInterceptor} but creating a
* projecting proxy in case the returned value is not of the return type of the invoked method.
*
* @author Oliver Gierke
*/
class ProjectingMethodInterceptor implements MethodInterceptor {
private final ProjectionFactory factory;
private final MethodInterceptor delegate;
/**
* Creates a new {@link ProjectingMethodInterceptor} using the given {@link ProjectionFactory} and delegate
* {@link MethodInterceptor}.
*
* @param factory the {@link ProjectionFactory} to use to create projections if types do not match.
* @param delegate the {@link MethodInterceptor} to trigger to create the source value.
*/
public ProjectingMethodInterceptor(ProjectionFactory factory, MethodInterceptor delegate) {
Assert.notNull(factory, "ProjectionFactory must not be null!");
Assert.notNull(delegate, "Delegate MethodInterceptor must not be null!");
this.factory = factory;
this.delegate = delegate;
}
/*
* (non-Javadoc)
* @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation)
*/
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Object result = delegate.invoke(invocation);
if (result == null) {
return null;
}
Class<?> returnType = invocation.getMethod().getReturnType();
return returnType.isAssignableFrom(result.getClass()) ? result : factory.createProjection(result, returnType);
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.projection;
/**
* Interface to allow the lookup of a projection interface by source type and name. This allows the definition of
* projections with the same name for different source types.
*
* @author Oliver Gierke
*/
public interface ProjectionDefinitions {
/**
* Returns the projection type for the given source type and name.
*
* @param sourceType must not be {@literal null}.
* @param name must not be {@literal null} or empty.
* @return
*/
Class<?> getProjectionType(Class<?> sourceType, String name);
/**
* Returns whether we have a projection registered for the given source type.
*
* @param sourceType must not be {@literal null}.
* @return
*/
boolean hasProjectionFor(Class<?> sourceType);
/**
* Returns the request parameter to be used to expose the projection to the web.
*
* @return the parameterName will never be {@literal null} or empty.
*/
String getParameterName();
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.projection;
/**
* A factory to create projecting instances for other objects usually used to allow easy creation of representation
* projections to define which properties of a domain objects shall be exported in which way.
*
* @author Oliver Gierke
*/
public interface ProjectionFactory {
/**
* Creates a projection of the given type for the given source object. The individual mapping strategy is defined by
* the implementations.
*
* @param source the object to create a projection for, can be {@literal null}
* @param projectionType the type to create.
* @return
*/
<T> T createProjection(Object source, Class<T> projectionType);
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.projection;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.BeanWrapper;
import org.springframework.data.util.DirectFieldAccessFallbackBeanWrapper;
import org.springframework.util.Assert;
/**
* Method interceptor to forward a delegation to bean property accessor methods to the property of a given target.
*
* @author Oliver Gierke
*/
class PropertyAccessingMethodInterceptor implements MethodInterceptor {
private final BeanWrapper target;
/**
* Creates a new {@link PropertyAccessingMethodInterceptor} for the given target object.
*
* @param target must not be {@literal null}.
* @param factory must not be {@literal null}.
*/
public PropertyAccessingMethodInterceptor(Object target) {
Assert.notNull(target, "Proxy target must not be null!");
this.target = new DirectFieldAccessFallbackBeanWrapper(target);
}
/*
* (non-Javadoc)
* @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation)
*/
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Method method = invocation.getMethod();
PropertyDescriptor descriptor = BeanUtils.findPropertyForMethod(method);
if (descriptor == null) {
throw new IllegalStateException("Invoked method is not a property accessor!");
}
return target.getPropertyValue(descriptor.getName());
}
}

View File

@@ -0,0 +1,178 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.projection;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.aop.framework.Advised;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.util.AnnotationDetectionMethodCallback;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
import com.fasterxml.jackson.annotation.JsonIgnore;
/**
* A {@link ProjectionFactory} to create JDK proxies to back interfaces and handle method invocations on them. By
* default two different kinds of methods are supported:
* <ol>
* <li>Bean property accessor methods - invocations will be delegated into a property lookup on the target instance.</li>
* <li>Arbitrary methods annotated with {@link Value} to contain a SpEL expression, which will be evaluated on
* invocation. The expressions can use {@code target} to refer to the proxy target.</li>
* </ol>
* In case the dlegating lookups result in an object of different type that the projection interface method's return
* type, another projection will be created to transparently mitigate between the types.
*
* @author Oliver Gierke
*/
public class ProxyProjectionFactory implements ProjectionFactory {
private final Map<Class<?>, Boolean> typeCache = new HashMap<Class<?>, Boolean>();
private BeanFactory beanFactory;
/**
* Creates a new {@link ProxyProjectionFactory} using the given {@link BeanFactory}.
*
* @param beanFactory can be {@literal null}. If {@literal null}, SpEL expressions at projection interfaces cannot use
* bean references.
*/
public ProxyProjectionFactory(BeanFactory beanFactory) {
this.beanFactory = beanFactory;
}
/*
* (non-Javadoc)
* @see org.springframework.data.rest.core.projection.ProjectionFactory#createProjection(java.lang.Object, java.lang.Class)
*/
@Override
@SuppressWarnings("unchecked")
public <T> T createProjection(Object source, Class<T> projectionType) {
Assert.isTrue(projectionType.isInterface(), "Projection type must be an interface!");
if (source == null) {
return null;
}
ProxyFactory factory = new ProxyFactory();
factory.setTarget(source);
factory.setOpaque(true);
factory.setInterfaces(projectionType, TargetClassAware.class);
factory.addAdvice(new TargetClassAwareMethodInterceptor(source.getClass()));
factory.addAdvice(getMethodInterceptor(source, projectionType));
return (T) factory.getProxy();
}
/**
* Returns the {@link MethodInterceptor} to add to the proxy.
*
* @param source must not be {@literal null}.
* @param target must not be {@literal null}.
* @return
*/
private MethodInterceptor getMethodInterceptor(Object source, Class<?> target) {
MethodInterceptor propertyInvocationInterceptor = new PropertyAccessingMethodInterceptor(source);
return new ProjectingMethodInterceptor(this, getSpelMethodInterceptorIfNecessary(target,
propertyInvocationInterceptor));
}
/**
* Inspects the given target type for methods with {@link Value} annotations and caches the result. Will create a
* {@link SpelEvaluatingMethodInterceptor} if an annotation was found or return the delegate as is if not.
*
* @param target the proxy target type.
* @param delegate the root {@link MethodInterceptor}.
* @return
*/
private MethodInterceptor getSpelMethodInterceptorIfNecessary(Class<?> target, MethodInterceptor delegate) {
if (!typeCache.containsKey(target)) {
AnnotationDetectionMethodCallback<Value> callback = new AnnotationDetectionMethodCallback<Value>(Value.class);
ReflectionUtils.doWithMethods(target, callback);
typeCache.put(target, callback.hasFoundAnnotation());
}
return typeCache.get(target) ? new SpelEvaluatingMethodInterceptor(delegate, target, beanFactory) : delegate;
}
/**
* Extension of {@link org.springframework.aop.TargetClassAware} to be able to ignore the getter on JSON rendering.
*
* @author Oliver Gierke
*/
public static interface TargetClassAware extends org.springframework.aop.TargetClassAware {
@JsonIgnore
Class<?> getTargetClass();
}
/**
* Custom {@link MethodInterceptor} to expose the proxy target class even if we set
* {@link ProxyFactory#setOpaque(boolean)} to true to prevent properties on {@link Advised} to be rendered.
*
* @author Oliver Gierke
*/
private static class TargetClassAwareMethodInterceptor implements MethodInterceptor {
private static final Method GET_TARGET_CLASS_METHOD;
private final Class<?> targetClass;
static {
try {
GET_TARGET_CLASS_METHOD = TargetClassAware.class.getMethod("getTargetClass");
} catch (NoSuchMethodException e) {
throw new IllegalStateException(e);
}
}
/**
* Creates a new {@link TargetClassAwareMethodInterceptor} with the given target class.
*
* @param targetClass must not be {@literal null}.
*/
public TargetClassAwareMethodInterceptor(Class<?> targetClass) {
Assert.notNull(targetClass, "Target class must not be null!");
this.targetClass = targetClass;
}
/*
* (non-Javadoc)
* @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation)
*/
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
if (invocation.getMethod().equals(GET_TARGET_CLASS_METHOD)) {
return targetClass;
}
return invocation.proceed();
}
}
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.projection;
import java.lang.reflect.Method;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.expression.BeanFactoryResolver;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ParserContext;
import org.springframework.expression.common.TemplateParserContext;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* {@link MethodInterceptor} to invoke a SpEL expression to compute the method result. Will forward the resolution to a
* delegate {@link MethodInterceptor} if no {@link Value} annotation is found.
*
* @author Oliver Gierke
*/
class SpelEvaluatingMethodInterceptor implements MethodInterceptor {
private final SpelExpressionParser parser;
private final ParserContext parserContext;
private final EvaluationContext evaluationContext;
private final MethodInterceptor delegate;
/**
* Creates a new {@link SpelEvaluatingMethodInterceptor} delegating to the given {@link MethodInterceptor} as fallback
* and exposing the given target object via {@code target} to the SpEl expressions. If a {@link BeanFactory} is given,
* bean references in SpEl expressions can be resolved as well.
*
* @param delegate must not be {@literal null}.
* @param target must not be {@literal null}.
* @param beanFactory can be {@literal null}.
*/
public SpelEvaluatingMethodInterceptor(MethodInterceptor delegate, Object target, BeanFactory beanFactory) {
Assert.notNull(delegate, "Delegate MethodInterceptor must not be null!");
Assert.notNull(target, "TargetObject must not be null!");
StandardEvaluationContext evaluationContext = new StandardEvaluationContext(new TargetWrapper(target));
if (beanFactory != null) {
evaluationContext.setBeanResolver(new BeanFactoryResolver(beanFactory));
}
this.evaluationContext = evaluationContext;
this.parser = new SpelExpressionParser();
this.parserContext = new TemplateParserContext();
this.delegate = delegate;
}
/*
* (non-Javadoc)
* @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation)
*/
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Method method = invocation.getMethod();
Value annotation = method.getAnnotation(Value.class);
if (annotation == null || !StringUtils.hasText(annotation.value())) {
return delegate.invoke(invocation);
}
Expression expression = parser.parseExpression(annotation.value(), parserContext);
return expression.getValue(evaluationContext);
}
/**
* Wrapper class to expose an object to the SpEL expression as {@code target}.
*
* @author Oliver Gierke
*/
static class TargetWrapper {
private final Object target;
public TargetWrapper(Object target) {
this.target = target;
}
/**
* @return the target
*/
public Object getTarget() {
return target;
}
}
}

View File

@@ -0,0 +1,147 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.config;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import org.junit.Test;
import org.springframework.data.rest.core.config.ProjectionDefinitionConfiguration.ProjectionDefinitionKey;
/**
* Unit tests for {@link ProjectionDefinitionConfiguration}.
*
* @author Oliver Gierke
*/
@SuppressWarnings("rawtypes")
public class ProjectionDefinitionConfigurationUnitTests {
/**
* @see DATAREST-221
*/
@Test(expected = IllegalArgumentException.class)
public void rejectsNullProjectionTypeForAutoConfiguration() {
new ProjectionDefinitionConfiguration().addProjection(null);
}
/**
* @see DATAREST-221
*/
@Test(expected = IllegalArgumentException.class)
public void rejectsUnannotatedClassForConfigurationShortcut() {
new ProjectionDefinitionConfiguration().addProjection(String.class);
}
/**
* @see DATAREST-221
*/
@Test(expected = IllegalArgumentException.class)
public void rejectsNullProjectionTypeForManualConfiguration() {
new ProjectionDefinitionConfiguration().addProjection(null, "name", Object.class);
}
/**
* @see DATAREST-221
*/
@Test(expected = IllegalArgumentException.class)
public void rejectsNullNameForManualConfiguration() {
new ProjectionDefinitionConfiguration().addProjection(String.class, (String) null, Object.class);
}
/**
* @see DATAREST-221
*/
@Test(expected = IllegalArgumentException.class)
public void rejectsEmptyNameForManualConfiguration() {
new ProjectionDefinitionConfiguration().addProjection(String.class, "", Object.class);
}
/**
* @see DATAREST-221
*/
@Test(expected = IllegalArgumentException.class)
public void rejectsEmptySourceTypes() {
new ProjectionDefinitionConfiguration().addProjection(String.class, "name", new Class<?>[0]);
}
/**
* @see DATAREST-221
*/
@Test
public void findsRegisteredProjection() {
ProjectionDefinitionConfiguration configuration = new ProjectionDefinitionConfiguration();
configuration.addProjection(Integer.class, "name", String.class);
assertThat(configuration.getProjectionType(String.class, "name"), is(equalTo((Class) Integer.class)));
}
/**
* @see DATAREST-221
*/
@Test
public void registersAnnotatedProjection() {
ProjectionDefinitionConfiguration configuration = new ProjectionDefinitionConfiguration();
configuration.addProjection(SampleProjection.class);
assertThat(configuration.getProjectionType(Integer.class, "name"), is(equalTo((Class) SampleProjection.class)));
}
/**
* @see DATAREST-221
*/
@Test
public void defaultsNameToSimpleClassNameIfNotAnnotated() {
ProjectionDefinitionConfiguration configuration = new ProjectionDefinitionConfiguration();
configuration.addProjection(Default.class);
assertThat(configuration.getProjectionType(Integer.class, "default"), is(equalTo((Class) Default.class)));
}
/**
* @see DATAREST-221
*/
@Test
public void definitionKeyEquals() {
ProjectionDefinitionKey objectNameKey = new ProjectionDefinitionKey(Object.class, "name");
ProjectionDefinitionKey sameObjectNameKey = new ProjectionDefinitionKey(Object.class, "name");
ProjectionDefinitionKey stringNameKey = new ProjectionDefinitionKey(String.class, "name");
ProjectionDefinitionKey objectOtherNameKey = new ProjectionDefinitionKey(Object.class, "otherName");
assertThat(objectNameKey, is(objectNameKey));
assertThat(objectNameKey, is(sameObjectNameKey));
assertThat(sameObjectNameKey, is(objectNameKey));
assertThat(objectNameKey, is(not(stringNameKey)));
assertThat(stringNameKey, is(not(objectNameKey)));
assertThat(objectNameKey, is(not(objectOtherNameKey)));
assertThat(objectOtherNameKey, is(not(objectNameKey)));
}
@Projection(name = "name", types = Integer.class)
interface SampleProjection {
}
@Projection(types = Integer.class)
interface Default {
}
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.projection;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;
/**
* Unit tests for {@link ProjectingMethodInterceptor}.
*
* @author Oliver Gierke
*/
@RunWith(MockitoJUnitRunner.class)
public class ProjectingMethodInterceptorUnitTests {
@Mock MethodInterceptor interceptor;
@Mock MethodInvocation invocation;
@Mock ProjectionFactory factory;
/**
* @see DATAREST-221
*/
@Test
public void wrapsDelegateResultInProxyIfTypesDontMatch() throws Throwable {
MethodInterceptor methodInterceptor = new ProjectingMethodInterceptor(new ProxyProjectionFactory(null), interceptor);
when(invocation.getMethod()).thenReturn(Helper.class.getMethod("getHelper"));
when(interceptor.invoke(invocation)).thenReturn("Foo");
assertThat(methodInterceptor.invoke(invocation), is(instanceOf(Helper.class)));
}
/**
* @see DATAREST-221
*/
@Test
public void retunsDelegateResultAsIsIfTypesMatch() throws Throwable {
MethodInterceptor methodInterceptor = new ProjectingMethodInterceptor(factory, interceptor);
when(invocation.getMethod()).thenReturn(Helper.class.getMethod("getString"));
when(interceptor.invoke(invocation)).thenReturn("Foo");
assertThat(methodInterceptor.invoke(invocation), is((Object) "Foo"));
}
/**
* @see DATAREST-221
*/
@Test
public void returnsNullAsIs() throws Throwable {
MethodInterceptor methodInterceptor = new ProjectingMethodInterceptor(factory, interceptor);
when(interceptor.invoke(invocation)).thenReturn(null);
assertThat(methodInterceptor.invoke(invocation), is(nullValue()));
}
interface Helper {
Helper getHelper();
String getString();
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.projection;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;
import org.springframework.beans.NotReadablePropertyException;
/**
* Unit tests for {@link PropertyAccessingMethodInterceptor}.
*
* @author Oliver Gierke
*/
@RunWith(MockitoJUnitRunner.class)
public class PropertyAccessingMethodInterceptorUnitTests {
@Mock MethodInvocation invocation;
/**
* @see DATAREST-221
*/
@Test
public void triggersPropertyAccessOnTarget() throws Throwable {
Source source = new Source();
source.firstname = "Dave";
when(invocation.getMethod()).thenReturn(Projection.class.getMethod("getFirstname"));
MethodInterceptor interceptor = new PropertyAccessingMethodInterceptor(source);
assertThat(interceptor.invoke(invocation), is((Object) "Dave"));
}
/**
* @see DATAREST-221
*/
@Test(expected = NotReadablePropertyException.class)
public void throwsAppropriateExceptionIfThePropertyCannotBeFound() throws Throwable {
when(invocation.getMethod()).thenReturn(Projection.class.getMethod("getLastname"));
new PropertyAccessingMethodInterceptor(new Source()).invoke(invocation);
}
static class Source {
String firstname;
}
interface Projection {
String getFirstname();
String getLastname();
}
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.projection;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import org.junit.Test;
import org.springframework.aop.TargetClassAware;
/**
* Unit tests for {@link ProxyProjectionFactory}.
*
* @author Oliver Gierke
*/
public class ProxyProjectionFactoryUnitTests {
ProjectionFactory factory = new ProxyProjectionFactory(null);
/**
* @see DATAREST-221
*/
@Test
public void createsProjectingProxy() {
Customer customer = new Customer();
customer.firstname = "Dave";
customer.lastname = "Matthews";
customer.address = new Address();
customer.address.city = "New York";
customer.address.zipCode = "ZIP";
CustomerExcerpt excerpt = factory.createProjection(customer, CustomerExcerpt.class);
assertThat(excerpt, is(instanceOf(TargetClassAware.class)));
assertThat(excerpt.getFirstname(), is("Dave"));
assertThat(excerpt.getAddress().getZipCode(), is("ZIP"));
}
/**
* @see DATAREST-221
*/
@Test
public void proxyExposesTargetClassAware() {
assertThat(factory.createProjection(new Object(), CustomerExcerpt.class), is(instanceOf(TargetClassAware.class)));
}
/**
* @see DATAREST-221
*/
@Test(expected = IllegalArgumentException.class)
public void rejectsNonInterfacesAsProjectionTarget() {
factory.createProjection(new Object(), Object.class);
}
static class Customer {
String firstname, lastname;
Address address;
}
static class Address {
String zipCode, city;
}
interface CustomerExcerpt {
String getFirstname();
AddressExcerpt getAddress();
}
interface AddressExcerpt {
String getZipCode();
}
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2014 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 org.springframework.data.rest.core.projection;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
/**
* Unit tests for {@link SpelEvaluatingMethodInterceptor}.
*
* @author Oliver Gierke
*/
@RunWith(MockitoJUnitRunner.class)
public class SpelEvaluatingMethodInterceptorUnitTests {
@Mock MethodInterceptor delegate;
@Mock MethodInvocation invocation;
/**
* @see DATAREST-221
*/
@Test
public void invokesMethodOnTarget() throws Throwable {
when(invocation.getMethod()).thenReturn(Projection.class.getMethod("propertyFromTarget"));
MethodInterceptor interceptor = new SpelEvaluatingMethodInterceptor(delegate, new Target(), null);
assertThat(interceptor.invoke(invocation), is((Object) "property"));
}
/**
* @see DATAREST-221
*/
@Test
public void invokesMethodOnBean() throws Throwable {
when(invocation.getMethod()).thenReturn(Projection.class.getMethod("invokeBean"));
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
factory.registerSingleton("someBean", new SomeBean());
SpelEvaluatingMethodInterceptor interceptor = new SpelEvaluatingMethodInterceptor(delegate, new Target(), factory);
assertThat(interceptor.invoke(invocation), is((Object) "value"));
}
interface Projection {
@Value("#{target.property}")
String propertyFromTarget();
@Value("#{@someBean.value}")
String invokeBean();
}
static class Target {
public String getProperty() {
return "property";
}
}
static class SomeBean {
public String getValue() {
return "value";
}
}
}