From af7e15b8e6aa8a9a4449b30a32e8894ea65a323c Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Mon, 24 Feb 2014 08:15:51 +0100 Subject: [PATCH] DATAREST-221 - Added support for projections. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- spring-data-rest-core/pom.xml | 8 +- .../data/rest/core/config/Projection.java | 49 +++++ .../ProjectionDefinitionConfiguration.java | 200 ++++++++++++++++++ .../config/RepositoryRestConfiguration.java | 36 +++- .../ProjectingMethodInterceptor.java | 65 ++++++ .../projection/ProjectionDefinitions.java | 49 +++++ .../core/projection/ProjectionFactory.java | 35 +++ .../PropertyAccessingMethodInterceptor.java | 65 ++++++ .../projection/ProxyProjectionFactory.java | 178 ++++++++++++++++ .../SpelEvaluatingMethodInterceptor.java | 111 ++++++++++ ...ctionDefinitionConfigurationUnitTests.java | 147 +++++++++++++ .../ProjectingMethodInterceptorUnitTests.java | 88 ++++++++ ...tyAccessingMethodInterceptorUnitTests.java | 76 +++++++ .../ProxyProjectionFactoryUnitTests.java | 92 ++++++++ ...lEvaluatingMethodInterceptorUnitTests.java | 93 ++++++++ .../AbstractRepositoryRestController.java | 48 ++--- .../rest/webmvc/PersistentEntityResource.java | 4 +- .../PersistentEntityResourceAssembler.java | 39 +++- .../rest/webmvc/RepositoryController.java | 9 +- .../webmvc/RepositoryEntityController.java | 57 ++--- ...RepositoryPropertyReferenceController.java | 29 +-- .../webmvc/RepositorySearchController.java | 23 +- .../rest/webmvc/RootResourceInformation.java | 2 +- ...tityResourceAssemblerArgumentResolver.java | 89 ++++++++ ...ResourceHandlerMethodArgumentResolver.java | 4 +- .../RepositoryRestMvcConfiguration.java | 84 ++++++-- ...MetadataHandlerMethodArgumentResolver.java | 9 +- ...ormationHandlerMethodArgumentResolver.java | 3 +- .../json/PersistentEntityJackson2Module.java | 37 ++-- ...PersistentEntityToJsonSchemaConverter.java | 13 +- .../support/PersistentEntityProjector.java | 66 ++++++ .../data/rest/webmvc/support/Projector.java | 38 ++++ ...ryConstraintViolationExceptionMessage.java | 33 +-- .../webmvc/support/RepositoryEntityLinks.java | 35 ++- .../AbstractControllerIntegrationTests.java | 14 +- ...itoryEntityControllerIntegrationTests.java | 4 +- ...RepositoryRestHandlerMappingUnitTests.java | 6 +- ...itorySearchControllerIntegrationTests.java | 3 +- .../data/rest/webmvc/jpa/JpaWebTests.java | 26 +++ .../data/rest/webmvc/jpa/OrderSummary.java | 29 +++ .../PersistentEntitySerializationTests.java | 15 +- .../ProjectionJacksonIntegrationTests.java | 110 ++++++++++ .../rest/webmvc/mongodb/MongoWebTests.java | 2 - ...RepositoryEntityLinksIntegrationTests.java | 15 ++ 44 files changed, 1947 insertions(+), 191 deletions(-) create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/Projection.java create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/ProjectionDefinitionConfiguration.java create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProjectingMethodInterceptor.java create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProjectionDefinitions.java create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProjectionFactory.java create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/PropertyAccessingMethodInterceptor.java create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProxyProjectionFactory.java create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/SpelEvaluatingMethodInterceptor.java create mode 100644 spring-data-rest-core/src/test/java/org/springframework/data/rest/core/config/ProjectionDefinitionConfigurationUnitTests.java create mode 100644 spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/ProjectingMethodInterceptorUnitTests.java create mode 100644 spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/PropertyAccessingMethodInterceptorUnitTests.java create mode 100644 spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/ProxyProjectionFactoryUnitTests.java create mode 100644 spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/SpelEvaluatingMethodInterceptorUnitTests.java create mode 100644 spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceAssemblerArgumentResolver.java rename spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/{ => config}/PersistentEntityResourceHandlerMethodArgumentResolver.java (96%) rename spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/{ => config}/ResourceMetadataHandlerMethodArgumentResolver.java (91%) rename spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/{ => config}/RootResourceInformationHandlerMethodArgumentResolver.java (97%) create mode 100644 spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/PersistentEntityProjector.java create mode 100644 spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/Projector.java create mode 100644 spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/OrderSummary.java create mode 100644 spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/ProjectionJacksonIntegrationTests.java diff --git a/spring-data-rest-core/pom.xml b/spring-data-rest-core/pom.xml index fb67104d3..1b06b2d67 100644 --- a/spring-data-rest-core/pom.xml +++ b/spring-data-rest-core/pom.xml @@ -16,7 +16,7 @@ - 0.9.0.RELEASE + 0.10.0.BUILD-SNAPSHOT 1.0.0.RELEASE 1.0.1 @@ -59,6 +59,12 @@ true + + com.fasterxml.jackson.core + jackson-annotations + ${jackson} + + diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/Projection.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/Projection.java new file mode 100644 index 000000000..c6fd8ede0 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/Projection.java @@ -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 ""; +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/ProjectionDefinitionConfiguration.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/ProjectionDefinitionConfiguration.java new file mode 100644 index 000000000..9175fe344 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/ProjectionDefinitionConfiguration.java @@ -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> projectionDefinitions; + private String parameterName = DEFAULT_PROJECTION_PARAMETER_NAME; + + public ProjectionDefinitionConfiguration() { + this.projectionDefinitions = new HashMap>(); + } + + /* + * (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; + } + } +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/RepositoryRestConfiguration.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/RepositoryRestConfiguration.java index b493b720d..4a8edc748 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/RepositoryRestConfiguration.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/RepositoryRestConfiguration.java @@ -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> exposeIdsFor = new ArrayList>(); 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; + } } diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProjectingMethodInterceptor.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProjectingMethodInterceptor.java new file mode 100644 index 000000000..b2ac58fbb --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProjectingMethodInterceptor.java @@ -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); + } +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProjectionDefinitions.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProjectionDefinitions.java new file mode 100644 index 000000000..709736c06 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProjectionDefinitions.java @@ -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(); +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProjectionFactory.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProjectionFactory.java new file mode 100644 index 000000000..132e40302 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProjectionFactory.java @@ -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 createProjection(Object source, Class projectionType); +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/PropertyAccessingMethodInterceptor.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/PropertyAccessingMethodInterceptor.java new file mode 100644 index 000000000..4f2b6b9e1 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/PropertyAccessingMethodInterceptor.java @@ -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()); + } +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProxyProjectionFactory.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProxyProjectionFactory.java new file mode 100644 index 000000000..7ab28f450 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/ProxyProjectionFactory.java @@ -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: + *
    + *
  1. Bean property accessor methods - invocations will be delegated into a property lookup on the target instance.
  2. + *
  3. 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.
  4. + *
+ * 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, Boolean> typeCache = new HashMap, 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 createProjection(Object source, Class 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 callback = new AnnotationDetectionMethodCallback(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(); + } + } +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/SpelEvaluatingMethodInterceptor.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/SpelEvaluatingMethodInterceptor.java new file mode 100644 index 000000000..2b8a9e542 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/projection/SpelEvaluatingMethodInterceptor.java @@ -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; + } + } +} diff --git a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/config/ProjectionDefinitionConfigurationUnitTests.java b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/config/ProjectionDefinitionConfigurationUnitTests.java new file mode 100644 index 000000000..f89f08965 --- /dev/null +++ b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/config/ProjectionDefinitionConfigurationUnitTests.java @@ -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 { + + } +} diff --git a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/ProjectingMethodInterceptorUnitTests.java b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/ProjectingMethodInterceptorUnitTests.java new file mode 100644 index 000000000..a7a4a0468 --- /dev/null +++ b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/ProjectingMethodInterceptorUnitTests.java @@ -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(); + } +} diff --git a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/PropertyAccessingMethodInterceptorUnitTests.java b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/PropertyAccessingMethodInterceptorUnitTests.java new file mode 100644 index 000000000..d09eb1321 --- /dev/null +++ b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/PropertyAccessingMethodInterceptorUnitTests.java @@ -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(); + } +} diff --git a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/ProxyProjectionFactoryUnitTests.java b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/ProxyProjectionFactoryUnitTests.java new file mode 100644 index 000000000..9e4619fa8 --- /dev/null +++ b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/ProxyProjectionFactoryUnitTests.java @@ -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(); + } +} diff --git a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/SpelEvaluatingMethodInterceptorUnitTests.java b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/SpelEvaluatingMethodInterceptorUnitTests.java new file mode 100644 index 000000000..b42dee267 --- /dev/null +++ b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/projection/SpelEvaluatingMethodInterceptorUnitTests.java @@ -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"; + } + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/AbstractRepositoryRestController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/AbstractRepositoryRestController.java index 6ded121f0..6d4d8ba9b 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/AbstractRepositoryRestController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/AbstractRepositoryRestController.java @@ -25,10 +25,10 @@ import java.util.Locale; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; import org.springframework.context.MessageSourceAware; +import org.springframework.context.support.MessageSourceAccessor; import org.springframework.core.convert.ConversionFailedException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.OptimisticLockingFailureException; @@ -57,23 +57,18 @@ import org.springframework.web.bind.annotation.ResponseBody; * @author Oliver Gierke */ @SuppressWarnings({ "rawtypes" }) -class AbstractRepositoryRestController implements MessageSourceAware, InitializingBean { +class AbstractRepositoryRestController implements MessageSourceAware { private static final Logger LOG = LoggerFactory.getLogger(AbstractRepositoryRestController.class); - private final PersistentEntityResourceAssembler perAssembler; - @Autowired(required = false) private ValidationExceptionHandler handler; @Autowired(required = false) private PlatformTransactionManager txMgr; - private MessageSource messageSource; - private PagedResourcesAssembler assembler; + private final PagedResourcesAssembler pagedResourcesAssembler; + private MessageSourceAccessor messageSourceAccessor; - public AbstractRepositoryRestController(PagedResourcesAssembler assembler, - PersistentEntityResourceAssembler entityResourceAssembler) { - - this.assembler = assembler; - this.perAssembler = entityResourceAssembler; + public AbstractRepositoryRestController(PagedResourcesAssembler pagedResourcesAssembler) { + this.pagedResourcesAssembler = pagedResourcesAssembler; } /* @@ -82,18 +77,7 @@ class AbstractRepositoryRestController implements MessageSourceAware, Initializi */ @Override public void setMessageSource(MessageSource messageSource) { - this.messageSource = messageSource; - } - - @Override - public void afterPropertiesSet() throws Exception { - - // FIXME: - - // if (null != txMgr) { - // txTmpl = new TransactionTemplate(txMgr); - // txTmpl.afterPropertiesSet(); - // } + this.messageSourceAccessor = new MessageSourceAccessor(messageSource); } @ExceptionHandler({ NullPointerException.class }) @@ -135,7 +119,7 @@ class AbstractRepositoryRestController implements MessageSourceAware, Initializi public ResponseEntity handleRepositoryConstraintViolationException(Locale locale, RepositoryConstraintViolationException rcve) { - return response(null, new RepositoryConstraintViolationExceptionMessage(rcve, messageSource, locale), + return response(null, new RepositoryConstraintViolationExceptionMessage(rcve, messageSourceAccessor), HttpStatus.BAD_REQUEST); } @@ -216,33 +200,33 @@ class AbstractRepositoryRestController implements MessageSourceAware, Initializi } @SuppressWarnings({ "unchecked" }) - protected Resources resultToResources(Object result) { + protected Resources resultToResources(Object result, PersistentEntityResourceAssembler assembler) { if (result instanceof Page) { Page page = (Page) result; return entitiesToResources(page, assembler); } else if (result instanceof Iterable) { - return entitiesToResources((Iterable) result); + return entitiesToResources((Iterable) result, assembler); } else if (null == result) { return new Resources(EMPTY_RESOURCE_LIST); } else { - Resource resource = perAssembler.toResource(result); + Resource resource = assembler.toResource(result); return new Resources(Collections.singletonList(resource)); } } protected Resources> entitiesToResources(Page page, - PagedResourcesAssembler assembler) { - - return assembler.toResource(page, perAssembler); + PersistentEntityResourceAssembler assembler) { + return pagedResourcesAssembler.toResource(page, assembler); } - protected Resources> entitiesToResources(Iterable entities) { + protected Resources> entitiesToResources(Iterable entities, + PersistentEntityResourceAssembler assembler) { List> resources = new ArrayList>(); for (Object obj : entities) { - resources.add(obj == null ? null : perAssembler.toResource(obj)); + resources.add(obj == null ? null : assembler.toResource(obj)); } return new Resources>(resources); diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PersistentEntityResource.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PersistentEntityResource.java index b5b8eff01..5c9408698 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PersistentEntityResource.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PersistentEntityResource.java @@ -34,8 +34,8 @@ public class PersistentEntityResource extends Resource { private final PersistentEntity entity; - public static PersistentEntityResource wrap(PersistentEntity entity, T obj) { - return new PersistentEntityResource(entity, obj); + public static PersistentEntityResource wrap(PersistentEntity entity, T obj, Link selfLink) { + return new PersistentEntityResource(entity, obj, selfLink); } public PersistentEntityResource(PersistentEntity entity, T content, Link... links) { diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PersistentEntityResourceAssembler.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PersistentEntityResourceAssembler.java index e1e5b4e59..c97804de4 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PersistentEntityResourceAssembler.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PersistentEntityResourceAssembler.java @@ -1,5 +1,5 @@ /* - * Copyright 2013 the original author or authors. + * Copyright 2013-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. @@ -18,32 +18,39 @@ package org.springframework.data.rest.webmvc; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.mapping.model.BeanWrapper; import org.springframework.data.repository.support.Repositories; +import org.springframework.data.rest.webmvc.support.Projector; import org.springframework.hateoas.EntityLinks; import org.springframework.hateoas.Link; import org.springframework.hateoas.ResourceAssembler; import org.springframework.util.Assert; /** + * {@link ResourceAssembler} to create {@link PersistentEntityResource}s for arbitrary domain objects. + * * @author Oliver Gierke */ -public class PersistentEntityResourceAssembler implements ResourceAssembler> { +public class PersistentEntityResourceAssembler implements ResourceAssembler> { private final Repositories repositories; private final EntityLinks entityLinks; + private final Projector projector; /** * Creates a new {@link PersistentEntityResourceAssembler}. * * @param repositories must not be {@literal null}. * @param entityLinks must not be {@literal null}. + * @param projections must not be {@literal null}. */ - public PersistentEntityResourceAssembler(Repositories repositories, EntityLinks entityLinks) { + public PersistentEntityResourceAssembler(Repositories repositories, EntityLinks entityLinks, Projector projector) { Assert.notNull(repositories, "Repositories must not be null!"); Assert.notNull(entityLinks, "EntityLinks must not be null!"); + Assert.notNull(projector, "PersistentEntityProjector must not be be null!"); this.repositories = repositories; this.entityLinks = entityLinks; + this.projector = projector; } /* @@ -51,22 +58,34 @@ public class PersistentEntityResourceAssembler implements ResourceAssembler toResource(T instance) { + public PersistentEntityResource toResource(Object instance) { PersistentEntity entity = repositories.getPersistentEntity(instance.getClass()); - - PersistentEntityResource resource = PersistentEntityResource.wrap(entity, instance); - resource.add(getSelfLinkFor(instance)); - return resource; + return PersistentEntityResource.wrap(entity, projector.project(instance), getSelfLinkFor(instance)); } + /** + * Creates the self link for the given domain instance. + * + * @param instance must be a managed entity, not {@literal null}. + * @return + */ public Link getSelfLinkFor(Object instance) { - PersistentEntity entity = repositories.getPersistentEntity(instance.getClass()); + Assert.notNull(instance, "Domain object must not be null!"); + + Class instanceType = instance.getClass(); + PersistentEntity entity = repositories.getPersistentEntity(instanceType); + + if (entity == null) { + throw new IllegalArgumentException(String.format("Cannot create self link for %s! No persistent entity found!", + instanceType)); + } BeanWrapper wrapper = BeanWrapper.create(instance, null); Object id = wrapper.getProperty(entity.getIdProperty()); - return entityLinks.linkForSingleResource(entity.getType(), id).withSelfRel(); + Link resourceLink = entityLinks.linkToSingleResource(entity.getType(), id); + return new Link(resourceLink.getHref(), Link.REL_SELF); } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryController.java index 979a0cb37..fa46d228b 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryController.java @@ -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. @@ -37,11 +37,10 @@ public class RepositoryController extends AbstractRepositoryRestController { private final ResourceMappings mappings; @Autowired - public RepositoryController(PagedResourcesAssembler assembler, - PersistentEntityResourceAssembler perAssembler, Repositories repositories, EntityLinks entityLinks, - ResourceMappings mappings) { + public RepositoryController(PagedResourcesAssembler assembler, Repositories repositories, + EntityLinks entityLinks, ResourceMappings mappings) { - super(assembler, perAssembler); + super(assembler); this.repositories = repositories; this.entityLinks = entityLinks; diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java index 6a2e96972..656429fa6 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java @@ -72,7 +72,6 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem private static final String BASE_MAPPING = "/{repository}"; private final EntityLinks entityLinks; - private final PersistentEntityResourceAssembler perAssembler; private final RepositoryRestConfiguration config; private final ConversionService conversionService; private final DomainObjectMerger domainObjectMerger; @@ -82,13 +81,11 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem @Autowired public RepositoryEntityController(Repositories repositories, RepositoryRestConfiguration config, EntityLinks entityLinks, PagedResourcesAssembler assembler, - PersistentEntityResourceAssembler perAssembler, @Qualifier("defaultConversionService") ConversionService conversionService, DomainObjectMerger domainObjectMerger) { - super(assembler, perAssembler); + super(assembler); this.entityLinks = entityLinks; - this.perAssembler = perAssembler; this.config = config; this.conversionService = conversionService; this.domainObjectMerger = domainObjectMerger; @@ -105,8 +102,9 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem @ResponseBody @RequestMapping(value = BASE_MAPPING, method = RequestMethod.GET) - public Resources listEntities(final RootResourceInformation resourceInformation, Pageable pageable, Sort sort) - throws ResourceNotFoundException, HttpRequestMethodNotSupportedException { + public Resources getCollectionResource(final RootResourceInformation resourceInformation, Pageable pageable, + Sort sort, PersistentEntityResourceAssembler assembler) throws ResourceNotFoundException, + HttpRequestMethodNotSupportedException { resourceInformation.verifySupportedMethod(HttpMethod.GET, ResourceType.COLLECTION); @@ -133,7 +131,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem .withRel(searchMappings.getRel())); } - Resources resources = resultToResources(results); + Resources resources = resultToResources(results, assembler); resources.add(links); return resources; } @@ -142,10 +140,11 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem @SuppressWarnings({ "unchecked" }) @RequestMapping(value = BASE_MAPPING, method = RequestMethod.GET, produces = { "application/x-spring-data-compact+json", "text/uri-list" }) - public Resources listEntitiesCompact(final RootResourceInformation repoRequest, Pageable pageable, Sort sort) - throws ResourceNotFoundException, HttpRequestMethodNotSupportedException { + public Resources getCollectionResourceCompact(RootResourceInformation repoRequest, Pageable pageable, Sort sort, + PersistentEntityResourceAssembler assembler) throws ResourceNotFoundException, + HttpRequestMethodNotSupportedException { - Resources resources = listEntities(repoRequest, pageable, sort); + Resources resources = getCollectionResource(repoRequest, pageable, sort, assembler); List links = new ArrayList(resources.getLinks()); for (Resource resource : ((Resources>) resources).getContent()) { @@ -170,11 +169,12 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem @ResponseBody @RequestMapping(value = BASE_MAPPING, method = RequestMethod.POST) public ResponseEntity postEntity(RootResourceInformation resourceInformation, - PersistentEntityResource payload) throws HttpRequestMethodNotSupportedException { + PersistentEntityResource payload, PersistentEntityResourceAssembler assembler) + throws HttpRequestMethodNotSupportedException { resourceInformation.verifySupportedMethod(HttpMethod.POST, ResourceType.COLLECTION); - return createAndReturn(payload.getContent(), resourceInformation.getInvoker()); + return createAndReturn(payload.getContent(), resourceInformation.getInvoker(), assembler); } /** @@ -186,8 +186,9 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem * @throws HttpRequestMethodNotSupportedException */ @RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.GET) - public ResponseEntity> getSingleEntity(RootResourceInformation resourceInformation, - @PathVariable String id) throws HttpRequestMethodNotSupportedException { + public ResponseEntity> getItemResource(RootResourceInformation resourceInformation, + @PathVariable String id, PersistentEntityResourceAssembler assembler) + throws HttpRequestMethodNotSupportedException { resourceInformation.verifySupportedMethod(HttpMethod.GET, ResourceType.ITEM); @@ -203,7 +204,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem return new ResponseEntity>(HttpStatus.NOT_FOUND); } - return new ResponseEntity>(perAssembler.toResource(domainObj), HttpStatus.OK); + return new ResponseEntity>(assembler.toResource(domainObj), HttpStatus.OK); } /** @@ -217,7 +218,8 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem */ @RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.PUT) public ResponseEntity putEntity(RootResourceInformation resourceInformation, - PersistentEntityResource payload, @PathVariable String id) throws HttpRequestMethodNotSupportedException { + PersistentEntityResource payload, @PathVariable String id, PersistentEntityResourceAssembler assembler) + throws HttpRequestMethodNotSupportedException { resourceInformation.verifySupportedMethod(HttpMethod.PUT, ResourceType.ITEM); @@ -229,10 +231,10 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem BeanWrapper incomingWrapper = BeanWrapper.create(payload.getContent(), conversionService); incomingWrapper.setProperty(payload.getPersistentEntity().getIdProperty(), id); - return createAndReturn(incomingWrapper.getBean(), invoker); + return createAndReturn(incomingWrapper.getBean(), invoker, assembler); } - return mergeAndReturn(payload.getContent(), domainObject, invoker, PUT); + return mergeAndReturn(payload.getContent(), domainObject, invoker, PUT, assembler); } /** @@ -247,8 +249,8 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem */ @RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.PATCH) public ResponseEntity patchEntity(RootResourceInformation resourceInformation, - PersistentEntityResource payload, @PathVariable String id) throws HttpRequestMethodNotSupportedException, - ResourceNotFoundException { + PersistentEntityResource payload, @PathVariable String id, PersistentEntityResourceAssembler assembler) + throws HttpRequestMethodNotSupportedException, ResourceNotFoundException { resourceInformation.verifySupportedMethod(HttpMethod.PATCH, ResourceType.ITEM); @@ -258,7 +260,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem throw new ResourceNotFoundException(); } - return mergeAndReturn(payload.getContent(), domainObject, resourceInformation.getInvoker(), PATCH); + return mergeAndReturn(payload.getContent(), domainObject, resourceInformation.getInvoker(), PATCH, assembler); } /** @@ -304,7 +306,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem * @return */ private ResponseEntity mergeAndReturn(Object incoming, Object domainObject, - RepositoryInvoker invoker, HttpMethod httpMethod) { + RepositoryInvoker invoker, HttpMethod httpMethod, PersistentEntityResourceAssembler assembler) { NullHandlingPolicy nullPolicy = httpMethod.equals(PATCH) ? IGNORE_NULLS : APPLY_NULLS; domainObjectMerger.merge(incoming, domainObject, nullPolicy); @@ -316,11 +318,11 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem HttpHeaders headers = new HttpHeaders(); if (PUT.equals(httpMethod)) { - headers.setLocation(URI.create(perAssembler.getSelfLinkFor(obj).getHref())); + headers.setLocation(URI.create(assembler.getSelfLinkFor(obj).getHref())); } if (config.isReturnBodyOnUpdate()) { - return ControllerUtils.toResponseEntity(HttpStatus.OK, headers, perAssembler.toResource(obj)); + return ControllerUtils.toResponseEntity(HttpStatus.OK, headers, assembler.toResource(obj)); } else { return ControllerUtils.toEmptyResponse(HttpStatus.NO_CONTENT, headers); } @@ -333,16 +335,17 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem * @param invoker * @return */ - private ResponseEntity createAndReturn(Object domainObject, RepositoryInvoker invoker) { + private ResponseEntity createAndReturn(Object domainObject, RepositoryInvoker invoker, + PersistentEntityResourceAssembler assembler) { publisher.publishEvent(new BeforeCreateEvent(domainObject)); Object savedObject = invoker.invokeSave(domainObject); publisher.publishEvent(new AfterCreateEvent(savedObject)); HttpHeaders headers = new HttpHeaders(); - headers.setLocation(URI.create(perAssembler.getSelfLinkFor(savedObject).getHref())); + headers.setLocation(URI.create(assembler.getSelfLinkFor(savedObject).expand().getHref())); - PersistentEntityResource resource = config.isReturnBodyOnCreate() ? perAssembler.toResource(savedObject) + PersistentEntityResource resource = config.isReturnBodyOnCreate() ? assembler.toResource(savedObject) : null; return ControllerUtils.toResponseEntity(HttpStatus.CREATED, headers, resource); } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryPropertyReferenceController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryPropertyReferenceController.java index b6fa8944c..d46665897 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryPropertyReferenceController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryPropertyReferenceController.java @@ -74,7 +74,6 @@ class RepositoryPropertyReferenceController extends AbstractRepositoryRestContro private static final String BASE_MAPPING = "/{repository}/{id}/{property}"; private final Repositories repositories; - private final PersistentEntityResourceAssembler perAssembler; private final ConversionService conversionService; private ApplicationEventPublisher publisher; @@ -82,12 +81,11 @@ class RepositoryPropertyReferenceController extends AbstractRepositoryRestContro @Autowired public RepositoryPropertyReferenceController(Repositories repositories, @Qualifier("defaultConversionService") ConversionService conversionService, - PagedResourcesAssembler assembler, PersistentEntityResourceAssembler perAssembler) { + PagedResourcesAssembler assembler) { - super(assembler, perAssembler); + super(assembler); this.repositories = repositories; - this.perAssembler = perAssembler; this.conversionService = conversionService; } @@ -102,7 +100,8 @@ class RepositoryPropertyReferenceController extends AbstractRepositoryRestContro @RequestMapping(value = BASE_MAPPING, method = RequestMethod.GET) public ResponseEntity followPropertyReference(final RootResourceInformation repoRequest, - @PathVariable String id, @PathVariable String property) throws Exception { + @PathVariable String id, @PathVariable String property, final PersistentEntityResourceAssembler assembler) + throws Exception { final HttpHeaders headers = new HttpHeaders(); @@ -120,7 +119,7 @@ class RepositoryPropertyReferenceController extends AbstractRepositoryRestContro List> resources = new ArrayList>(); for (Object obj : (Iterable) prop.propertyValue) { - resources.add(perAssembler.toResource(obj)); + resources.add(assembler.toResource(obj)); } return new Resources>(resources); @@ -130,14 +129,14 @@ class RepositoryPropertyReferenceController extends AbstractRepositoryRestContro Map> resources = new HashMap>(); for (Map.Entry entry : ((Map) prop.propertyValue).entrySet()) { - resources.put(entry.getKey(), perAssembler.toResource(entry.getValue())); + resources.put(entry.getKey(), assembler.toResource(entry.getValue())); } return new Resource(resources); } else { - PersistentEntityResource resource = perAssembler.toResource(prop.propertyValue); + PersistentEntityResource resource = assembler.toResource(prop.propertyValue); headers.set("Content-Location", resource.getId().getHref()); return resource; } @@ -190,7 +189,8 @@ class RepositoryPropertyReferenceController extends AbstractRepositoryRestContro @RequestMapping(value = BASE_MAPPING + "/{propertyId}", method = RequestMethod.GET) public ResponseEntity followPropertyReference(final RootResourceInformation repoRequest, - @PathVariable String id, @PathVariable String property, final @PathVariable String propertyId) throws Exception { + @PathVariable String id, @PathVariable String property, final @PathVariable String propertyId, + final PersistentEntityResourceAssembler assembler) throws Exception { final HttpHeaders headers = new HttpHeaders(); @@ -210,7 +210,7 @@ class RepositoryPropertyReferenceController extends AbstractRepositoryRestContro if (propertyId.equals(sId)) { - PersistentEntityResource resource = perAssembler.toResource(obj); + PersistentEntityResource resource = assembler.toResource(obj); headers.set("Content-Location", resource.getId().getHref()); return resource; } @@ -223,7 +223,7 @@ class RepositoryPropertyReferenceController extends AbstractRepositoryRestContro if (propertyId.equals(sId)) { - PersistentEntityResource resource = perAssembler.toResource(entry.getValue()); + PersistentEntityResource resource = assembler.toResource(entry.getValue()); headers.set("Content-Location", resource.getId().getHref()); return resource; } @@ -242,9 +242,10 @@ class RepositoryPropertyReferenceController extends AbstractRepositoryRestContro @RequestMapping(value = BASE_MAPPING, method = RequestMethod.GET, produces = { "application/x-spring-data-compact+json", "text/uri-list" }) public ResponseEntity followPropertyReferenceCompact(RootResourceInformation repoRequest, - @PathVariable String id, @PathVariable String property) throws Exception { + @PathVariable String id, @PathVariable String property, PersistentEntityResourceAssembler assembler) + throws Exception { - ResponseEntity response = followPropertyReference(repoRequest, id, property); + ResponseEntity response = followPropertyReference(repoRequest, id, property, assembler); if (response.getStatusCode() != HttpStatus.OK) { return response; @@ -259,7 +260,7 @@ class RepositoryPropertyReferenceController extends AbstractRepositoryRestContro List links = new ArrayList(); ControllerLinkBuilder linkBuilder = linkTo(methodOn(RepositoryPropertyReferenceController.class) - .followPropertyReference(repoRequest, id, property)); + .followPropertyReference(repoRequest, id, property, assembler)); if (resource instanceof Resource) { diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySearchController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySearchController.java index 3b88eb48b..05e4dcaef 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySearchController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySearchController.java @@ -67,18 +67,17 @@ class RepositorySearchController extends AbstractRepositoryRestController { /** * Creates a new {@link RepositorySearchController} using the given {@link PagedResourcesAssembler}, - * {@link PersistentEntityResourceAssembler}, {@link EntityLinks} and {@link ResourceMappings}. + * {@link EntityLinks} and {@link ResourceMappings}. * * @param assembler must not be {@literal null}. - * @param perAssembler must not be {@literal null}. * @param entityLinks must not be {@literal null}. * @param mappings must not be {@literal null}. */ @Autowired - public RepositorySearchController(PagedResourcesAssembler assembler, - PersistentEntityResourceAssembler perAssembler, EntityLinks entityLinks, ResourceMappings mappings) { + public RepositorySearchController(PagedResourcesAssembler assembler, EntityLinks entityLinks, + ResourceMappings mappings) { - super(assembler, perAssembler); + super(assembler); Assert.notNull(entityLinks, "EntityLinks must not be null!"); Assert.notNull(mappings, "ResourceMappings must not be null!"); @@ -129,10 +128,10 @@ class RepositorySearchController extends AbstractRepositoryRestController { @ResponseBody @RequestMapping(value = BASE_MAPPING + "/{search}", method = RequestMethod.GET) public ResponseEntity> executeSearch(RootResourceInformation resourceInformation, WebRequest request, - @PathVariable String search, Pageable pageable) { + @PathVariable String search, Pageable pageable, PersistentEntityResourceAssembler assembler) { Method method = checkExecutability(resourceInformation, search); - Resources resources = executeQueryMethod(resourceInformation.getInvoker(), request, method, pageable); + Resources resources = executeQueryMethod(resourceInformation.getInvoker(), request, method, pageable, assembler); return new ResponseEntity>(resources, HttpStatus.OK); } @@ -150,10 +149,12 @@ class RepositorySearchController extends AbstractRepositoryRestController { @RequestMapping(value = BASE_MAPPING + "/{method}", method = RequestMethod.GET, // produces = { "application/x-spring-data-compact+json" }) public ResourceSupport executeSearchCompact(RootResourceInformation resourceInformation, WebRequest request, - @PathVariable String repository, @PathVariable String search, Pageable pageable) { + @PathVariable String repository, @PathVariable String search, Pageable pageable, + PersistentEntityResourceAssembler assembler) { Method method = checkExecutability(resourceInformation, search); - ResourceSupport resource = executeQueryMethod(resourceInformation.getInvoker(), request, method, pageable); + ResourceSupport resource = executeQueryMethod(resourceInformation.getInvoker(), request, method, pageable, + assembler); List links = new ArrayList(); @@ -209,12 +210,12 @@ class RepositorySearchController extends AbstractRepositoryRestController { * @return */ private Resources executeQueryMethod(final RepositoryInvoker invoker, WebRequest request, Method method, - Pageable pageable) { + Pageable pageable, PersistentEntityResourceAssembler assembler) { Map parameters = request.getParameterMap(); Object result = invoker.invokeQueryMethod(method, parameters, pageable, null); - return resultToResources(result); + return resultToResources(result, assembler); } /** diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RootResourceInformation.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RootResourceInformation.java index 2d8174746..ccccb504e 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RootResourceInformation.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RootResourceInformation.java @@ -34,7 +34,7 @@ import org.springframework.web.HttpRequestMethodNotSupportedException; * @author Jon Brisbin * @author Oliver Gierke */ -class RootResourceInformation { +public class RootResourceInformation { private final ResourceMetadata resourceMetadata; private final RepositoryInvoker invoker; diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceAssemblerArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceAssemblerArgumentResolver.java new file mode 100644 index 000000000..21efa127f --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceAssemblerArgumentResolver.java @@ -0,0 +1,89 @@ +/* + * 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.webmvc.config; + +import org.springframework.core.MethodParameter; +import org.springframework.data.repository.support.Repositories; +import org.springframework.data.rest.core.projection.ProjectionDefinitions; +import org.springframework.data.rest.core.projection.ProjectionFactory; +import org.springframework.data.rest.webmvc.PersistentEntityResourceAssembler; +import org.springframework.data.rest.webmvc.support.PersistentEntityProjector; +import org.springframework.hateoas.EntityLinks; +import org.springframework.util.Assert; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * {@link HandlerMethodArgumentResolver} to create {@link PersistentEntityResourceAssembler}s. + * + * @author Oliver Gierke + */ +public class PersistentEntityResourceAssemblerArgumentResolver implements HandlerMethodArgumentResolver { + + private final Repositories repositories; + private final EntityLinks entityLinks; + private final ProjectionDefinitions projectionDefinitions; + private final ProjectionFactory projectionFactory; + + /** + * Creates a new {@link PersistentEntityResourceAssemblerArgumentResolver} for the given {@link Repositories}, + * {@link EntityLinks}, {@link ProjectionDefinitions} and {@link ProjectionFactory}. + * + * @param repositories must not be {@literal null}. + * @param entityLinks must not be {@literal null}. + * @param projectionDefinitions must not be {@literal null}. + * @param projectionFactory must not be {@literal null}. + */ + public PersistentEntityResourceAssemblerArgumentResolver(Repositories repositories, EntityLinks entityLinks, + ProjectionDefinitions projectionDefinitions, ProjectionFactory projectionFactory) { + + Assert.notNull(repositories, "Repositories must not be null!"); + Assert.notNull(entityLinks, "EntityLinks must not be null!"); + Assert.notNull(projectionDefinitions, "ProjectionDefinitions must not be null!"); + Assert.notNull(projectionFactory, "ProjectionFactory must not be null!"); + + this.repositories = repositories; + this.entityLinks = entityLinks; + this.projectionDefinitions = projectionDefinitions; + this.projectionFactory = projectionFactory; + } + + /* + * (non-Javadoc) + * @see org.springframework.web.method.support.HandlerMethodArgumentResolver#supportsParameter(org.springframework.core.MethodParameter) + */ + @Override + public boolean supportsParameter(MethodParameter parameter) { + return PersistentEntityResourceAssembler.class.equals(parameter.getParameterType()); + } + + /* + * (non-Javadoc) + * @see org.springframework.web.method.support.HandlerMethodArgumentResolver#resolveArgument(org.springframework.core.MethodParameter, org.springframework.web.method.support.ModelAndViewContainer, org.springframework.web.context.request.NativeWebRequest, org.springframework.web.bind.support.WebDataBinderFactory) + */ + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + + String projectionParameter = webRequest.getParameter(projectionDefinitions.getParameterName()); + PersistentEntityProjector projector = new PersistentEntityProjector(projectionDefinitions, projectionFactory, + projectionParameter); + + return new PersistentEntityResourceAssembler(repositories, entityLinks, projector); + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PersistentEntityResourceHandlerMethodArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceHandlerMethodArgumentResolver.java similarity index 96% rename from spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PersistentEntityResourceHandlerMethodArgumentResolver.java rename to spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceHandlerMethodArgumentResolver.java index c45472580..236993ec9 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PersistentEntityResourceHandlerMethodArgumentResolver.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceHandlerMethodArgumentResolver.java @@ -13,13 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.rest.webmvc; +package org.springframework.data.rest.webmvc.config; import java.util.List; import javax.servlet.http.HttpServletRequest; import org.springframework.core.MethodParameter; +import org.springframework.data.rest.webmvc.PersistentEntityResource; +import org.springframework.data.rest.webmvc.RootResourceInformation; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java index 7c0223745..4932ef165 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java @@ -18,7 +18,9 @@ package org.springframework.data.rest.webmvc.config; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.beans.factory.ObjectFactory; @@ -34,9 +36,12 @@ import org.springframework.context.annotation.Lazy; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.context.support.ReloadableResourceBundleMessageSource; import org.springframework.core.convert.support.ConfigurableConversionService; +import org.springframework.core.env.Environment; import org.springframework.data.repository.support.DomainClassConverter; import org.springframework.data.repository.support.Repositories; import org.springframework.data.rest.core.UriToEntityConverter; +import org.springframework.data.rest.core.config.Projection; +import org.springframework.data.rest.core.config.ProjectionDefinitionConfiguration; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.core.event.AnnotatedHandlerBeanPostProcessor; import org.springframework.data.rest.core.event.ValidatingRepositoryEventListener; @@ -44,15 +49,12 @@ import org.springframework.data.rest.core.invoke.DefaultRepositoryInvokerFactory import org.springframework.data.rest.core.invoke.RepositoryInvokerFactory; import org.springframework.data.rest.core.mapping.ResourceDescription; import org.springframework.data.rest.core.mapping.ResourceMappings; +import org.springframework.data.rest.core.projection.ProxyProjectionFactory; import org.springframework.data.rest.core.support.DomainObjectMerger; import org.springframework.data.rest.core.util.UUIDConverter; -import org.springframework.data.rest.webmvc.PersistentEntityResourceAssembler; -import org.springframework.data.rest.webmvc.PersistentEntityResourceHandlerMethodArgumentResolver; import org.springframework.data.rest.webmvc.RepositoryRestController; import org.springframework.data.rest.webmvc.RepositoryRestHandlerAdapter; import org.springframework.data.rest.webmvc.RepositoryRestHandlerMapping; -import org.springframework.data.rest.webmvc.ResourceMetadataHandlerMethodArgumentResolver; -import org.springframework.data.rest.webmvc.RootResourceInformationHandlerMethodArgumentResolver; import org.springframework.data.rest.webmvc.ServerHttpRequestMethodArgumentResolver; import org.springframework.data.rest.webmvc.convert.UriListHttpMessageConverter; import org.springframework.data.rest.webmvc.json.Jackson2DatatypeHelper; @@ -62,6 +64,9 @@ import org.springframework.data.rest.webmvc.support.HttpMethodHandlerMethodArgum import org.springframework.data.rest.webmvc.support.JpaHelper; import org.springframework.data.rest.webmvc.support.RepositoryEntityLinks; import org.springframework.data.rest.webmvc.support.ValidationExceptionHandler; +import org.springframework.data.util.AnnotatedTypeScanner; +import org.springframework.data.web.HateoasPageableHandlerMethodArgumentResolver; +import org.springframework.data.web.HateoasSortHandlerMethodArgumentResolver; import org.springframework.data.web.config.HateoasAwareSpringDataWebConfiguration; import org.springframework.format.support.DefaultFormattingConversionService; import org.springframework.hateoas.EntityLinks; @@ -123,6 +128,8 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon RepositoryRestMvcConfiguration.class.getClassLoader()); @Autowired ListableBeanFactory beanFactory; + @Autowired Environment environment; + @Autowired(required = false) List> resourceProcessors = Collections.emptyList(); @Autowired(required = false) RelProvider relProvider; @Autowired(required = false) CurieProvider curieProvider; @@ -187,7 +194,14 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon */ @Bean public RepositoryRestConfiguration config() { - RepositoryRestConfiguration config = new RepositoryRestConfiguration(); + + ProjectionDefinitionConfiguration configuration = new ProjectionDefinitionConfiguration(); + + for (Class projection : getProjections()) { + configuration.addProjection(projection); + } + + RepositoryRestConfiguration config = new RepositoryRestConfiguration(configuration); configureRepositoryRestConfiguration(config); return config; } @@ -275,7 +289,7 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon @Bean public PersistentEntityToJsonSchemaConverter jsonSchemaConverter() { return new PersistentEntityToJsonSchemaConverter(repositories(), resourceMappings(), - resourceDescriptionMessageSourceAccessor()); + resourceDescriptionMessageSourceAccessor(), entityLinks()); } /** @@ -372,11 +386,6 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon return new UriListHttpMessageConverter(); } - @Bean - public PersistentEntityResourceAssembler persistentEntityResourceAssembler() { - return new PersistentEntityResourceAssembler(repositories(), entityLinks()); - } - /** * Special {@link org.springframework.web.servlet.HandlerAdapter} that only recognizes handler methods defined in the * provided controller classes. @@ -472,10 +481,45 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon return messageConverters; } + /* + * (non-Javadoc) + * @see org.springframework.data.web.config.HateoasAwareSpringDataWebConfiguration#pageableResolver() + */ + @Bean + @Override + public HateoasPageableHandlerMethodArgumentResolver pageableResolver() { + + HateoasPageableHandlerMethodArgumentResolver resolver = super.pageableResolver(); + resolver.setPageParameterName(config().getPageParamName()); + resolver.setSizeParameterName(config().getLimitParamName()); + + return resolver; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.web.config.HateoasAwareSpringDataWebConfiguration#sortResolver() + */ + @Bean + @Override + public HateoasSortHandlerMethodArgumentResolver sortResolver() { + + HateoasSortHandlerMethodArgumentResolver resolver = super.sortResolver(); + resolver.setSortParameter(config().getSortParamName()); + + return resolver; + } + private List defaultMethodArgumentResolvers() { - return Arrays.asList(pageableResolver(), sortResolver(), serverHttpRequestMethodArgumentResolver(), - repoRequestArgumentResolver(), persistentEntityArgumentResolver(), - resourceMetadataHandlerMethodArgumentResolver(), HttpMethodHandlerMethodArgumentResolver.INSTANCE); + + PersistentEntityResourceAssemblerArgumentResolver peraResolver = new PersistentEntityResourceAssemblerArgumentResolver( + repositories(), entityLinks(), config().projectionConfiguration(), new ProxyProjectionFactory(beanFactory)); + + return Arrays + .asList(pageableResolver(), sortResolver(), serverHttpRequestMethodArgumentResolver(), + repoRequestArgumentResolver(), persistentEntityArgumentResolver(), + resourceMetadataHandlerMethodArgumentResolver(), HttpMethodHandlerMethodArgumentResolver.INSTANCE, + peraResolver); } private ObjectMapper basicObjectMapper() { @@ -496,6 +540,18 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon return this.relProvider != null ? relProvider : new EvoInflectorRelProvider(); } + @SuppressWarnings("unchecked") + private Set> getProjections() { + + Set packagesToScan = new HashSet(); + + for (Class domainType : repositories()) { + packagesToScan.add(domainType.getPackage().getName()); + } + + return new AnnotatedTypeScanner(Projection.class).findTypes(packagesToScan); + } + /** * Override this method to add additional configuration. * diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ResourceMetadataHandlerMethodArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/ResourceMetadataHandlerMethodArgumentResolver.java similarity index 91% rename from spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ResourceMetadataHandlerMethodArgumentResolver.java rename to spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/ResourceMetadataHandlerMethodArgumentResolver.java index cbcd2ca64..a374ecd7d 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ResourceMetadataHandlerMethodArgumentResolver.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/ResourceMetadataHandlerMethodArgumentResolver.java @@ -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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.rest.webmvc; +package org.springframework.data.rest.webmvc.config; import static org.springframework.util.ClassUtils.*; import static org.springframework.util.StringUtils.*; @@ -33,6 +33,8 @@ import org.springframework.web.method.support.ModelAndViewContainer; import org.springframework.web.util.UrlPathHelper; /** + * {@link HandlerMethodArgumentResolver} to create {@link ResourceMetadata} instances. + * * @author Jon Brisbin * @author Oliver Gierke */ @@ -42,6 +44,9 @@ public class ResourceMetadataHandlerMethodArgumentResolver implements HandlerMet private final ResourceMappings mappings; /** + * Creates a new {@link ResourceMetadataHandlerMethodArgumentResolver} for the given {@link Repositories} and + * {@link ResourceMappings}. + * * @param repositories must not be {@literal null}. * @param mappings must not be {@literal null}. */ diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RootResourceInformationHandlerMethodArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RootResourceInformationHandlerMethodArgumentResolver.java similarity index 97% rename from spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RootResourceInformationHandlerMethodArgumentResolver.java rename to spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RootResourceInformationHandlerMethodArgumentResolver.java index b89a95517..d20a08719 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RootResourceInformationHandlerMethodArgumentResolver.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RootResourceInformationHandlerMethodArgumentResolver.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.rest.webmvc; +package org.springframework.data.rest.webmvc.config; import org.springframework.core.MethodParameter; import org.springframework.data.mapping.PersistentEntity; @@ -21,6 +21,7 @@ import org.springframework.data.repository.support.Repositories; import org.springframework.data.rest.core.invoke.RepositoryInvoker; import org.springframework.data.rest.core.invoke.RepositoryInvokerFactory; import org.springframework.data.rest.core.mapping.ResourceMetadata; +import org.springframework.data.rest.webmvc.RootResourceInformation; import org.springframework.util.Assert; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java index b1843bbdc..44d596178 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java @@ -29,15 +29,14 @@ import org.springframework.data.mapping.Association; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.SimpleAssociationHandler; -import org.springframework.data.mapping.model.BeanWrapper; import org.springframework.data.repository.support.Repositories; +import org.springframework.data.rest.core.Path; import org.springframework.data.rest.core.UriToEntityConverter; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.core.mapping.ResourceMapping; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.core.mapping.ResourceMetadata; import org.springframework.data.rest.webmvc.PersistentEntityResource; -import org.springframework.data.rest.webmvc.support.RepositoryLinkBuilder; import org.springframework.hateoas.Link; import org.springframework.hateoas.Resource; import org.springframework.util.Assert; @@ -99,12 +98,12 @@ public class PersistentEntityJackson2Module extends SimpleModule { Assert.notNull(config, "RepositoryRestConfiguration must not be null!"); Assert.notNull(converter, "UriToEntityConverter must not be null!"); - addSerializer(new PersistentEntityResourceSerializer(mappings, config)); + addSerializer(new PersistentEntityResourceSerializer(mappings)); setSerializerModifier(new AssociationOmittingSerializerModifier(repositories, mappings, config)); setDeserializerModifier(new AssociationUriResolvingDeserializerModifier(repositories, converter, mappings)); } - public static boolean maybeAddAssociationLink(RepositoryLinkBuilder builder, ResourceMappings mappings, + public static boolean maybeAddAssociationLink(Path path, ResourceMappings mappings, PersistentProperty persistentProperty, List links) { Assert.isTrue(persistentProperty.isAssociation(), "PersistentProperty must be an association!"); @@ -117,7 +116,8 @@ public class PersistentEntityJackson2Module extends SimpleModule { ResourceMapping propertyMapping = ownerMetadata.getMappingFor(persistentProperty); if (propertyMapping.isExported()) { - links.add(builder.slash(propertyMapping.getPath()).withRel(propertyMapping.getRel())); + + links.add(new Link(path.slash(propertyMapping.getPath()).toString(), propertyMapping.getRel())); // This is an association. We added a Link. return true; } @@ -135,25 +135,20 @@ public class PersistentEntityJackson2Module extends SimpleModule { private static class PersistentEntityResourceSerializer extends StdSerializer> { private final ResourceMappings mappings; - private final RepositoryRestConfiguration configuration; /** - * Creates a new {@link PersistentEntityResourceSerializer} using the given {@link ResourceMappings} and - * {@link RepositoryRestConfiguration}. + * Creates a new {@link PersistentEntityResourceSerializer} using the given {@link ResourceMappings}. * * @param mappings must not be {@literal null}. - * @param configuration must not be {@literal null}. */ @SuppressWarnings({ "unchecked", "rawtypes" }) - private PersistentEntityResourceSerializer(ResourceMappings mappings, RepositoryRestConfiguration configuration) { + private PersistentEntityResourceSerializer(ResourceMappings mappings) { super((Class) PersistentEntityResource.class); Assert.notNull(mappings, "ResourceMappings must not be null!"); - Assert.notNull(configuration, "RepositoryRestConfiguration must not be null!"); this.mappings = mappings; - this.configuration = configuration; } /* @@ -168,19 +163,17 @@ public class PersistentEntityJackson2Module extends SimpleModule { LOG.debug("Serializing PersistentEntity " + resource.getPersistentEntity()); } - Object obj = resource.getContent(); - PersistentEntity entity = resource.getPersistentEntity(); - BeanWrapper, Object> wrapper = BeanWrapper.create(obj, null); - Object entityId = wrapper.getProperty(entity.getIdProperty()); - ResourceMetadata metadata = mappings.getMappingFor(entity.getType()); - URI baseUri = configuration.getBaseUri(); + final Link id = resource.getId(); + + if (id == null) { + throw new JsonGenerationException(String.format("No self link found resource %s!", resource)); + } - final RepositoryLinkBuilder builder = new RepositoryLinkBuilder(metadata, baseUri).slash(entityId); final List links = new ArrayList(); links.addAll(resource.getLinks()); // Add associations as links - entity.doWithAssociations(new SimpleAssociationHandler() { + resource.getPersistentEntity().doWithAssociations(new SimpleAssociationHandler() { /* * (non-Javadoc) @@ -190,11 +183,11 @@ public class PersistentEntityJackson2Module extends SimpleModule { public void doWithAssociation(Association> association) { PersistentProperty property = association.getInverse(); - maybeAddAssociationLink(builder, mappings, property, links); + maybeAddAssociationLink(new Path(id.expand().getHref()), mappings, property, links); } }); - Resource resourceToRender = new Resource(obj, links); + Resource resourceToRender = new Resource(resource.getContent(), links); provider.defaultSerializeValue(resourceToRender, jgen); } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverter.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverter.java index c9040a69f..e05c7d5af 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverter.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverter.java @@ -34,13 +34,14 @@ import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.SimpleAssociationHandler; import org.springframework.data.mapping.SimplePropertyHandler; import org.springframework.data.repository.support.Repositories; +import org.springframework.data.rest.core.Path; import org.springframework.data.rest.core.mapping.ResourceDescription; import org.springframework.data.rest.core.mapping.ResourceMapping; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.core.mapping.ResourceMetadata; import org.springframework.data.rest.webmvc.json.JsonSchema.ArrayProperty; import org.springframework.data.rest.webmvc.json.JsonSchema.Property; -import org.springframework.data.rest.webmvc.support.RepositoryLinkBuilder; +import org.springframework.hateoas.EntityLinks; import org.springframework.hateoas.Link; import org.springframework.util.Assert; @@ -57,6 +58,7 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric private final ResourceMappings mappings; private final Repositories repositories; private final MessageSourceAccessor accessor; + private final EntityLinks entityLinks; /** * Creates a new {@link PersistentEntityToJsonSchemaConverter} for the given {@link Repositories} and @@ -67,7 +69,7 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric * @param accessor */ public PersistentEntityToJsonSchemaConverter(Repositories repositories, ResourceMappings mappings, - MessageSourceAccessor accessor) { + MessageSourceAccessor accessor, EntityLinks entityLinks) { Assert.notNull(repositories, "Repositories must not be null!"); Assert.notNull(mappings, "ResourceMappings must not be null!"); @@ -75,6 +77,7 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric this.repositories = repositories; this.mappings = mappings; this.accessor = accessor; + this.entityLinks = entityLinks; for (Class domainType : repositories) { convertiblePairs.add(new ConvertiblePair(domainType, JsonSchema.class)); @@ -111,7 +114,7 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric @Override public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { - PersistentEntity persistentEntity = repositories.getPersistentEntity((Class) source); + final PersistentEntity persistentEntity = repositories.getPersistentEntity((Class) source); final ResourceMetadata metadata = mappings.getMappingFor(persistentEntity.getType()); final JsonSchema jsonSchema = new JsonSchema(persistentEntity.getName(), accessor.getMessage(metadata .getItemResourceDescription())); @@ -159,8 +162,8 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric return; } - RepositoryLinkBuilder builder = new RepositoryLinkBuilder(metadata, null).slash("{id}"); - maybeAddAssociationLink(builder, mappings, persistentProperty, links); + Link link = entityLinks.linkToCollectionResource(persistentEntity.getType()); + maybeAddAssociationLink(new Path(link.getHref()), mappings, persistentProperty, links); } }); diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/PersistentEntityProjector.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/PersistentEntityProjector.java new file mode 100644 index 000000000..5530f17c4 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/PersistentEntityProjector.java @@ -0,0 +1,66 @@ +/* + * 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.webmvc.support; + +import org.springframework.data.rest.core.projection.ProjectionDefinitions; +import org.springframework.data.rest.core.projection.ProjectionFactory; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * {@link Projector} looking up a projection by name for the given source type. + * + * @author Oliver Gierke + */ +public class PersistentEntityProjector implements Projector { + + private final ProjectionDefinitions projectionDefinitions; + private final ProjectionFactory factory; + private final String projection; + + /** + * Creates a new {@link PersistentEntityProjector} using the given {@link ProjectionDefinitions}, + * {@link ProjectionFactory} and projection name. + * + * @param projectionDefinitions must not be {@literal null}. + * @param factory must not be {@literal null}. + * @param projection can be empty. + */ + public PersistentEntityProjector(ProjectionDefinitions projectionDefinitions, ProjectionFactory factory, + String projection) { + + Assert.notNull(projectionDefinitions, "ProjectionDefinitions must not be null!"); + Assert.notNull(factory, "ProjectionFactory must not be null!"); + + this.projectionDefinitions = projectionDefinitions; + this.factory = factory; + this.projection = projection; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.webmvc.support.Projector#project(java.lang.Object) + */ + public Object project(Object source) { + + if (!StringUtils.hasText(projection)) { + return source; + } + + Class projectionType = projectionDefinitions.getProjectionType(source.getClass(), projection); + return factory.createProjection(source, projectionType); + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/Projector.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/Projector.java new file mode 100644 index 000000000..90bc90445 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/Projector.java @@ -0,0 +1,38 @@ +/* + * 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.webmvc.support; + +/** + * @author Oliver Gierke + */ +public interface Projector { + + public Object project(Object source); + + enum NoOpProjector implements Projector { + + INSTANCE; + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.webmvc.support.Projector#project(java.lang.Object) + */ + @Override + public Object project(Object source) { + return source; + } + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryConstraintViolationExceptionMessage.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryConstraintViolationExceptionMessage.java index 98bc4f799..932e94f82 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryConstraintViolationExceptionMessage.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryConstraintViolationExceptionMessage.java @@ -2,9 +2,8 @@ package org.springframework.data.rest.webmvc.support; import java.util.ArrayList; import java.util.List; -import java.util.Locale; -import org.springframework.context.MessageSource; +import org.springframework.context.support.MessageSourceAccessor; import org.springframework.data.rest.core.RepositoryConstraintViolationException; import org.springframework.validation.FieldError; @@ -18,22 +17,23 @@ public class RepositoryConstraintViolationExceptionMessage { private final List errors = new ArrayList(); public RepositoryConstraintViolationExceptionMessage(RepositoryConstraintViolationException violationException, - MessageSource msgSrc, Locale locale) { + MessageSourceAccessor accessor) { + + for (FieldError fieldError : violationException.getErrors().getFieldErrors()) { - for (FieldError fe : violationException.getErrors().getFieldErrors()) { List args = new ArrayList(); - args.add(fe.getObjectName()); - args.add(fe.getField()); - args.add(fe.getRejectedValue()); - if (null != fe.getArguments()) { - for (Object o : fe.getArguments()) { + args.add(fieldError.getObjectName()); + args.add(fieldError.getField()); + args.add(fieldError.getRejectedValue()); + if (null != fieldError.getArguments()) { + for (Object o : fieldError.getArguments()) { args.add(o); } } - String msg = msgSrc.getMessage(fe.getCode(), args.toArray(), fe.getDefaultMessage(), locale); - this.errors.add(new ValidationError(fe.getObjectName(), msg, String.format("%s", fe.getRejectedValue()), fe - .getField())); + String message = accessor.getMessage(fieldError.getCode(), args.toArray(), fieldError.getDefaultMessage()); + this.errors.add(new ValidationError(fieldError.getObjectName(), message, String.format("%s", + fieldError.getRejectedValue()), fieldError.getField())); } } @@ -43,10 +43,11 @@ public class RepositoryConstraintViolationExceptionMessage { } public static class ValidationError { - String entity; - String message; - String invalidValue; - String property; + + private final String entity; + private final String message; + private final String invalidValue; + private final String property; public ValidationError(String entity, String message, String invalidValue, String property) { this.entity = entity; diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryEntityLinks.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryEntityLinks.java index 6ec0575c4..225d963fb 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryEntityLinks.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryEntityLinks.java @@ -15,8 +15,11 @@ */ package org.springframework.data.rest.webmvc.support; +import static org.springframework.hateoas.TemplateVariable.VariableType.*; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.repository.support.Repositories; +import org.springframework.data.rest.core.config.ProjectionDefinitionConfiguration; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.core.mapping.ResourceMetadata; @@ -24,6 +27,7 @@ import org.springframework.data.web.HateoasPageableHandlerMethodArgumentResolver import org.springframework.hateoas.EntityLinks; import org.springframework.hateoas.Link; import org.springframework.hateoas.LinkBuilder; +import org.springframework.hateoas.TemplateVariable; import org.springframework.hateoas.TemplateVariables; import org.springframework.hateoas.UriTemplate; import org.springframework.hateoas.core.AbstractEntityLinks; @@ -105,18 +109,22 @@ public class RepositoryEntityLinks extends AbstractEntityLinks { public Link linkToCollectionResource(Class type) { ResourceMetadata metadata = mappings.getMappingFor(type); + TemplateVariables variables = new TemplateVariables(); + String href = linkFor(type).withSelfRel().getHref(); if (metadata.isPagingResource()) { - - Link link = linkFor(type).withSelfRel(); - String href = link.getHref(); UriComponents components = UriComponentsBuilder.fromUriString(href).build(); - TemplateVariables variables = resolver.getPaginationTemplateVariables(null, components); - - return new Link(new UriTemplate(href, variables), metadata.getRel()); + variables = variables.concat(resolver.getPaginationTemplateVariables(null, components)); } - return linkFor(type).withRel(metadata.getRel()); + ProjectionDefinitionConfiguration projectionConfiguration = config.projectionConfiguration(); + + if (projectionConfiguration.hasProjectionFor(type)) { + variables = variables.concat(new TemplateVariable(projectionConfiguration.getParameterName(), REQUEST_PARAM)); + } + + return variables.asList().isEmpty() ? linkFor(type).withRel(metadata.getRel()) : new Link(new UriTemplate(href, + variables), metadata.getRel()); } /* @@ -127,6 +135,17 @@ public class RepositoryEntityLinks extends AbstractEntityLinks { public Link linkToSingleResource(Class type, Object id) { ResourceMetadata metadata = mappings.getMappingFor(type); - return linkFor(type).slash(id).withRel(metadata.getItemResourceRel()); + Link link = linkFor(type).slash(id).withRel(metadata.getItemResourceRel()); + ProjectionDefinitionConfiguration projectionConfiguration = config.projectionConfiguration(); + + if (!projectionConfiguration.hasProjectionFor(type)) { + return link; + } + + String parameterName = projectionConfiguration.getParameterName(); + TemplateVariables templateVariables = new TemplateVariables(new TemplateVariable(parameterName, REQUEST_PARAM)); + UriTemplate template = new UriTemplate(link.getHref(), templateVariables); + + return new Link(template.toString(), metadata.getItemResourceRel()); } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/AbstractControllerIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/AbstractControllerIntegrationTests.java index 2abd72ee8..ba5b8a553 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/AbstractControllerIntegrationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/AbstractControllerIntegrationTests.java @@ -18,6 +18,8 @@ package org.springframework.data.rest.webmvc; import org.junit.Before; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.repository.support.Repositories; import org.springframework.data.rest.core.Path; @@ -25,6 +27,7 @@ import org.springframework.data.rest.core.invoke.RepositoryInvokerFactory; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.core.mapping.ResourceMetadata; import org.springframework.data.rest.webmvc.config.RepositoryRestMvcConfiguration; +import org.springframework.data.rest.webmvc.support.Projector; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; @@ -40,11 +43,20 @@ import org.springframework.web.context.request.WebRequest; * @author Oliver Gierke */ @RunWith(SpringJUnit4ClassRunner.class) -@ContextConfiguration(classes = { RepositoryRestMvcConfiguration.class }) +@ContextConfiguration public abstract class AbstractControllerIntegrationTests { public static final Path BASE = new Path("http://localhost"); + @Configuration + static class TestConfiguration extends RepositoryRestMvcConfiguration { + + @Bean + public PersistentEntityResourceAssembler persistentEntityResourceAssembler() { + return new PersistentEntityResourceAssembler(repositories(), entityLinks(), Projector.NoOpProjector.INSTANCE); + } + } + @Autowired Repositories repositories; @Autowired RepositoryInvokerFactory invokerFactory; @Autowired ResourceMappings mappings; diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java index 32aeb7c73..401c65130 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java @@ -45,7 +45,7 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll repository.save(new Address()); RootResourceInformation request = getResourceInformation(Address.class); - controller.listEntities(request, null, null); + controller.getCollectionResource(request, null, null, null); } /** @@ -56,6 +56,6 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll RootResourceInformation request = getResourceInformation(Address.class); - controller.postEntity(request, null); + controller.postEntity(request, null, null); } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryRestHandlerMappingUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryRestHandlerMappingUnitTests.java index 024a389b5..b3bb8837d 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryRestHandlerMappingUnitTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryRestHandlerMappingUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013 the original author or authors. + * Copyright 2013-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. @@ -64,8 +64,8 @@ public class RepositoryRestHandlerMappingUnitTests { mockRequest = new MockHttpServletRequest(); - listEntitiesMethod = RepositoryEntityController.class.getMethod("listEntities", RootResourceInformation.class, - Pageable.class, Sort.class); + listEntitiesMethod = RepositoryEntityController.class.getMethod("getCollectionResource", + RootResourceInformation.class, Pageable.class, Sort.class, PersistentEntityResourceAssembler.class); } @Test(expected = IllegalArgumentException.class) diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java index cb6722630..f01a086c5 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java @@ -46,6 +46,7 @@ public class RepositorySearchControllerIntegrationTests extends AbstractControll @Autowired TestDataPopulator loader; @Autowired RepositorySearchController controller; + @Autowired PersistentEntityResourceAssembler assembler; @Before public void setUp() { @@ -86,7 +87,7 @@ public class RepositorySearchControllerIntegrationTests extends AbstractControll RootResourceInformation resourceInformation = getResourceInformation(Person.class); ResponseEntity> response = controller.executeSearch(resourceInformation, getRequest(parameters), - "firstname", null); + "firstname", null, assembler); ResourceTester tester = ResourceTester.of(response.getBody()); PagedResources pagedResources = tester.assertIsPage(); diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java index f1a49c92c..3db199df8 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java @@ -40,6 +40,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriTemplate; import com.fasterxml.jackson.databind.ObjectMapper; @@ -430,6 +431,31 @@ public class JpaWebTests extends AbstractWebIntegrationTests { andExpect(status().isMethodNotAllowed()); } + /** + * Checks, that the server only returns the properties contained in the projection requested. + * + * @see OrderSummary + * @see DATAREST-221 + */ + @Test + public void returnsProjectionIfRequested() throws Exception { + + Link orders = discoverUnique("orders"); + + MockHttpServletResponse response = request(orders); + Link orderLink = assertContentLinkWithRel("self", response, true).expand(); + + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(orderLink.getHref()); + String uri = builder.queryParam("projection", "summary").build().toUriString(); + + response = mvc.perform(get(uri)). // + andExpect(status().isOk()). // + andExpect(jsonPath("$.price", is(2.5))).// + andReturn().getResponse(); + + assertJsonPathDoesntExist("$.lineItems", response); + } + /** * Asserts the {@link Person} resource the given link points to contains siblings with the given names. * diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/OrderSummary.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/OrderSummary.java new file mode 100644 index 000000000..a3017f8ac --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/OrderSummary.java @@ -0,0 +1,29 @@ +/* + * 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.webmvc.jpa; + +import java.math.BigDecimal; + +import org.springframework.data.rest.core.config.Projection; + +/** + * @author Oliver Gierke + */ +@Projection(name = "summary", types = Order.class) +public interface OrderSummary { + + BigDecimal getPrice(); +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/PersistentEntitySerializationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/PersistentEntitySerializationTests.java index 66daba460..12461432a 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/PersistentEntitySerializationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/PersistentEntitySerializationTests.java @@ -45,7 +45,6 @@ import org.springframework.hateoas.PagedResources.PageMetadata; import org.springframework.hateoas.hal.HalLinkDiscoverer; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.util.UriTemplate; @@ -114,8 +113,11 @@ public class PersistentEntitySerializationTests { PersistentEntity persistentEntity = repositories.getPersistentEntity(Person.class); Person person = people.save(new Person("John", "Doe")); + PersistentEntityResource resource = PersistentEntityResource.wrap(persistentEntity, person, new Link( + "/person/" + person.getId())); + StringWriter writer = new StringWriter(); - mapper.writeValue(writer, PersistentEntityResource.wrap(persistentEntity, person)); + mapper.writeValue(writer, resource); String s = writer.toString(); @@ -180,14 +182,15 @@ public class PersistentEntitySerializationTests { user.address.street = "Street"; PersistentEntityResource userResource = new PersistentEntityResource( - repositories.getPersistentEntity(User.class), user); + repositories.getPersistentEntity(User.class), user, new Link("/users/1")); PagedResources> persistentEntityResource = new PagedResources>( Arrays.asList(userResource), new PageMetadata(1, 0, 10)); assertThat( mapper.writeValueAsString(persistentEntityResource), - is("{\"_embedded\":{\"users\":[{\"address\":{\"street\":\"Street\"}}]},\"page\":{\"size\":1,\"totalElements\":10,\"totalPages\":10,\"number\":0}}")); + is("{\"_embedded\":{\"users\":[{\"address\":{\"street\":\"Street\"},\"_links\":{\"self\":{\"href\":\"/users/1\"}}}]}," + + "\"page\":{\"size\":1,\"totalElements\":10,\"totalPages\":10,\"number\":0}}")); } /** @@ -199,12 +202,12 @@ public class PersistentEntitySerializationTests { Person creator = new Person("Dave", "Matthews"); Order order = new Order(creator); - ReflectionTestUtils.setField(order, "id", 1L); order.add(new LineItem("first")); order.add(new LineItem("second")); PersistentEntityResource orderResource = new PersistentEntityResource( repositories.getPersistentEntity(Order.class), order); + orderResource.add(new Link("/orders/1")); @SuppressWarnings("unchecked") PagedResources> persistentEntityResource = new PagedResources>( @@ -212,7 +215,7 @@ public class PersistentEntitySerializationTests { assertThat(mapper.writeValueAsString(persistentEntityResource), is("{\"_embedded\":{\"orders\":[{\"lineItems\":[{\"name\":\"first\"},{\"name\":\"second\"}],\"price\":2.5" - + ",\"_links\":{\"creator\":{\"href\":\"http://localhost:8080/orders/1/creator\"}}}]},\"" + + ",\"_links\":{\"self\":{\"href\":\"/orders/1\"},\"creator\":{\"href\":\"/orders/1/creator\"}}}]},\"" + "page\":{\"size\":1,\"totalElements\":10,\"totalPages\":10,\"number\":0}}")); } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/ProjectionJacksonIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/ProjectionJacksonIntegrationTests.java new file mode 100644 index 000000000..6a87c4636 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/ProjectionJacksonIntegrationTests.java @@ -0,0 +1,110 @@ +/* + * 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.webmvc.json; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +import java.util.Arrays; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.data.rest.core.projection.ProjectionFactory; +import org.springframework.data.rest.core.projection.ProxyProjectionFactory; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.core.EvoInflectorRelProvider; +import org.springframework.hateoas.hal.Jackson2HalModule; +import org.springframework.hateoas.hal.Jackson2HalModule.HalHandlerInstantiator; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; + +/** + * Integration tests for Jackson marshalling of projected objects. + * + * @author Oliver Gierke + */ +public class ProjectionJacksonIntegrationTests { + + ObjectMapper mapper; + ProjectionFactory factory = new ProxyProjectionFactory(null); + + @Before + public void setUp() { + + this.mapper = new ObjectMapper(); + this.mapper.registerModule(new Jackson2HalModule()); + this.mapper.setHandlerInstantiator(new HalHandlerInstantiator(new EvoInflectorRelProvider(), null)); + } + + /** + * @see DATAREST-221 + */ + @Test + public void considersJacksonAnnotationsOnProjectionInterfaces() throws Exception { + + Customer customer = new Customer(); + customer.firstname = "Dave"; + customer.lastname = "Matthews"; + customer.address = new Address(); + + CustomerProjection projection = factory.createProjection(customer, CustomerProjection.class); + + String result = mapper.writeValueAsString(projection); + assertThat(JsonPath.read(result, "$firstname"), is((Object) "Dave")); + } + + /** + * @see DATAREST-221 + */ + @Test + public void rendersHalContentCorrectly() throws Exception { + + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new Jackson2HalModule()); + mapper.setHandlerInstantiator(new Jackson2HalModule.HalHandlerInstantiator(new EvoInflectorRelProvider(), null)); + + Customer customer = new Customer(); + customer.firstname = "Dave"; + customer.lastname = "Matthews"; + customer.address = new Address(); + + CustomerProjection projection = factory.createProjection(customer, CustomerProjection.class); + Resources resources = new Resources(Arrays.asList(projection)); + + String result = mapper.writeValueAsString(resources); + + assertThat(JsonPath.read(result, "$_embedded.customers[0].firstname"), is((Object) "Dave")); + } + + static class Customer { + String firstname, lastname; + Address address; + } + + static class Address { + + } + + interface CustomerProjection { + + String getFirstname(); + + @JsonIgnore + String getLastname(); + } +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java index eb4ed7a8f..aafd96b6a 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java @@ -27,7 +27,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.rest.webmvc.AbstractWebIntegrationTests; import org.springframework.hateoas.Link; import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.web.servlet.result.MockMvcResultHandlers; /** * Integration tests for MongoDB repositories. @@ -94,7 +93,6 @@ public class MongoWebTests extends AbstractWebIntegrationTests { Link usersLink = discoverUnique("users"); Link userLink = assertHasContentLinkWithRel("self", request(usersLink)); follow(userLink).// - andDo(MockMvcResultHandlers.print()). // andExpect(jsonPath("$.address.zipCode").value(is(notNullValue()))); } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/RepositoryEntityLinksIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/RepositoryEntityLinksIntegrationTests.java index 7e92eb01f..b87442518 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/RepositoryEntityLinksIntegrationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/RepositoryEntityLinksIntegrationTests.java @@ -20,8 +20,10 @@ import static org.junit.Assert.*; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.webmvc.AbstractControllerIntegrationTests; import org.springframework.data.rest.webmvc.jpa.JpaRepositoryConfig; +import org.springframework.data.rest.webmvc.jpa.Order; import org.springframework.data.rest.webmvc.jpa.Person; import org.springframework.hateoas.Link; import org.springframework.test.context.ContextConfiguration; @@ -34,6 +36,7 @@ import org.springframework.test.context.ContextConfiguration; @ContextConfiguration(classes = JpaRepositoryConfig.class) public class RepositoryEntityLinksIntegrationTests extends AbstractControllerIntegrationTests { + @Autowired RepositoryRestConfiguration configuration; @Autowired RepositoryEntityLinks entityLinks; @Test @@ -54,4 +57,16 @@ public class RepositoryEntityLinksIntegrationTests extends AbstractControllerInt assertThat(link.getVariableNames(), hasItems("page", "size", "sort")); assertThat(link.getRel(), is("people")); } + + /** + * @see DATAREST-221 + */ + @Test + public void returnsLinkWithProjectionTemplateVariableIfProjectionIsDefined() { + + Link link = entityLinks.linkToSingleResource(Order.class, 1); + + assertThat(link.isTemplated(), is(true)); + assertThat(link.getVariableNames(), hasItem(configuration.projectionConfiguration().getParameterName())); + } }