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,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";
}
}
}