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 4297137cc..f3a2133e7 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 @@ -33,10 +33,10 @@ import org.springframework.beans.factory.config.PropertiesFactoryBean; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.ComponentScan.Filter; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.ImportResource; +import org.springframework.context.annotation.ComponentScan.Filter; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.context.support.ReloadableResourceBundleMessageSource; import org.springframework.core.Ordered; @@ -86,7 +86,11 @@ import org.springframework.data.rest.webmvc.convert.UriListHttpMessageConverter; import org.springframework.data.rest.webmvc.json.DomainObjectReader; import org.springframework.data.rest.webmvc.json.EnumTranslator; import org.springframework.data.rest.webmvc.json.Jackson2DatatypeHelper; +import org.springframework.data.rest.webmvc.json.JacksonMappingAwareSortTranslator; import org.springframework.data.rest.webmvc.json.JacksonSerializers; +import org.springframework.data.rest.webmvc.json.MappingAwareDefaultedPageableArgumentResolver; +import org.springframework.data.rest.webmvc.json.MappingAwarePageableArgumentResolver; +import org.springframework.data.rest.webmvc.json.MappingAwareSortArgumentResolver; import org.springframework.data.rest.webmvc.json.PersistentEntityJackson2Module; import org.springframework.data.rest.webmvc.json.PersistentEntityToJsonSchemaConverter; import org.springframework.data.rest.webmvc.spi.BackendIdConverter; @@ -94,6 +98,7 @@ import org.springframework.data.rest.webmvc.spi.BackendIdConverter.DefaultIdConv import org.springframework.data.rest.webmvc.support.BackendIdHandlerMethodArgumentResolver; import org.springframework.data.rest.webmvc.support.DefaultedPageableHandlerMethodArgumentResolver; import org.springframework.data.rest.webmvc.support.DelegatingHandlerMapping; +import org.springframework.data.rest.webmvc.support.DomainClassResolver; import org.springframework.data.rest.webmvc.support.ETagArgumentResolver; import org.springframework.data.rest.webmvc.support.HttpMethodHandlerMethodArgumentResolver; import org.springframework.data.rest.webmvc.support.JpaHelper; @@ -146,6 +151,7 @@ import com.fasterxml.jackson.databind.SerializationFeature; * @author Oliver Gierke * @author Jon Brisbin * @author Greg Turnquist + * @author Mark Paluch */ @Configuration @EnableHypermediaSupport(type = HypermediaType.HAL) @@ -687,6 +693,12 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon return resolver; } + @Bean + public JacksonMappingAwareSortTranslator sortMethodArgumentTranslator() { + return new JacksonMappingAwareSortTranslator(objectMapper(), repositories(), + DomainClassResolver.create(repositories(), resourceMappings(), baseUri())); + } + @Bean public PluginRegistry> backendIdConverterRegistry() { @@ -713,10 +725,16 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon resourceMappings()); HateoasPageableHandlerMethodArgumentResolver pageableResolver = pageableResolver(); - HandlerMethodArgumentResolver defaultedPageableResolver = new DefaultedPageableHandlerMethodArgumentResolver( - pageableResolver); - return Arrays.asList(defaultedPageableResolver, pageableResolver, sortResolver(), + + HandlerMethodArgumentResolver sortResolver = new MappingAwareSortArgumentResolver(sortMethodArgumentTranslator(), + sortResolver()); + HandlerMethodArgumentResolver jacksonPageableResolver = new MappingAwarePageableArgumentResolver( + sortMethodArgumentTranslator(), pageableResolver); + HandlerMethodArgumentResolver defaultedPageableResolver = new MappingAwareDefaultedPageableArgumentResolver( + sortMethodArgumentTranslator(), pageableResolver); + + return Arrays.asList(defaultedPageableResolver, jacksonPageableResolver, sortResolver, serverHttpRequestMethodArgumentResolver(), repoRequestArgumentResolver(), persistentEntityArgumentResolver(), resourceMetadataHandlerMethodArgumentResolver(), HttpMethodHandlerMethodArgumentResolver.INSTANCE, peraResolver, backendIdHandlerMethodArgumentResolver(), eTagArgumentResolver()); diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java index 25b40d6d4..cdf3a1993 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java @@ -16,7 +16,6 @@ package org.springframework.data.rest.webmvc.json; import java.io.InputStream; -import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.Map.Entry; @@ -31,12 +30,8 @@ import org.springframework.data.rest.webmvc.mapping.AssociationLinks; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.util.Assert; -import com.fasterxml.jackson.databind.BeanDescription; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.introspect.BasicClassIntrospector; -import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; -import com.fasterxml.jackson.databind.introspect.ClassIntrospector; import com.fasterxml.jackson.databind.node.ObjectNode; /** @@ -45,13 +40,13 @@ import com.fasterxml.jackson.databind.node.ObjectNode; * detect nested objects, lookup the original value and apply the merge recursively. * * @author Oliver Gierke + * @author Mark Paluch * @since 2.2 */ public class DomainObjectReader { private final PersistentEntities entities; private final AssociationLinks associationLinks; - private final ClassIntrospector introspector; /** * Creates a new {@link DomainObjectReader} using the given {@link PersistentEntities} and {@link ResourceMappings}. @@ -66,13 +61,12 @@ public class DomainObjectReader { this.entities = entities; this.associationLinks = new AssociationLinks(mappings); - this.introspector = new BasicClassIntrospector(); } /** * Reads the given input stream into an {@link ObjectNode} and applies that to the given existing instance. * - * @param request must not be {@literal null}. + * @param source must not be {@literal null}. * @param target must not be {@literal null}. * @param mapper must not be {@literal null}. * @return @@ -110,7 +104,7 @@ public class DomainObjectReader { Assert.notNull(entity, "No PersistentEntity found for ".concat(type.getName()).concat("!")); - final MappedProperties properties = getJacksonProperties(entity, mapper); + final MappedProperties properties = MappedProperties.fromJacksonProperties(entity, mapper); entity.doWithProperties(new SimplePropertyHandler() { @@ -157,6 +151,7 @@ public class DomainObjectReader { * @return * @throws Exception */ + @SuppressWarnings("unchecked") private T doMerge(ObjectNode root, T target, ObjectMapper mapper) throws Exception { Assert.notNull(root, "Root ObjectNode must not be null!"); @@ -169,7 +164,7 @@ public class DomainObjectReader { return mapper.readerForUpdating(target).readValue(root); } - MappedProperties mappedProperties = getJacksonProperties(entity, mapper); + MappedProperties mappedProperties = MappedProperties.fromJacksonProperties(entity, mapper); for (Iterator> i = root.fields(); i.hasNext();) { @@ -254,63 +249,4 @@ public class DomainObjectReader { } } } - - /** - * Returns the {@link MappedProperties} for the given {@link PersistentEntity}. - * - * @param entity must not be {@literal null}. - * @param mapper must not be {@literal null}. - * @return - */ - private MappedProperties getJacksonProperties(PersistentEntity entity, ObjectMapper mapper) { - - BeanDescription description = introspector.forDeserialization(mapper.getDeserializationConfig(), - mapper.constructType(entity.getType()), mapper.getDeserializationConfig()); - - return new MappedProperties(entity, description); - } - - /** - * Simple value object to capture a mapping of Jackson mapped field names and {@link PersistentProperty} instances. - * - * @author Oliver Gierke - */ - private static class MappedProperties { - - private final Map, String> propertyToFieldName; - private final Map> fieldNameToProperty; - - /** - * Creates a new {@link MappedProperties} instance for the given {@link PersistentEntity} and - * {@link BeanDescription}. - * - * @param entity must not be {@literal null}. - * @param description must not be {@literal null}. - */ - public MappedProperties(PersistentEntity entity, BeanDescription description) { - - this.propertyToFieldName = new HashMap, String>(); - this.fieldNameToProperty = new HashMap>(); - - for (BeanPropertyDefinition property : description.findProperties()) { - - PersistentProperty persistentProperty = entity.getPersistentProperty(property.getInternalName()); - - propertyToFieldName.put(persistentProperty, property.getName()); - fieldNameToProperty.put(property.getName(), persistentProperty); - } - } - - public String getMappedName(PersistentProperty property) { - return propertyToFieldName.get(property); - } - - public boolean hasPersistentPropertyForField(String fieldName) { - return fieldNameToProperty.containsKey(fieldName); - } - - public PersistentProperty getPersistentProperty(String fieldName) { - return fieldNameToProperty.get(fieldName); - } - } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonMappingAwareSortTranslator.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonMappingAwareSortTranslator.java new file mode 100644 index 000000000..8954cf5a9 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonMappingAwareSortTranslator.java @@ -0,0 +1,136 @@ +/* + * Copyright 2016 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 java.util.ArrayList; +import java.util.List; + +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Order; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.repository.support.Repositories; +import org.springframework.data.rest.webmvc.support.DomainClassResolver; +import org.springframework.util.Assert; +import org.springframework.web.context.request.NativeWebRequest; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Translator for {@link Sort} arguments that is aware of Jackson-Mapping on domain classes. Jackson field names are + * translated to {@link PersistentProperty} names. Domain class are looked up by resolving request URLs to mapped + * repositories. + * + * @author Mark Paluch + */ +public class JacksonMappingAwareSortTranslator { + + private final ObjectMapper objectMapper; + private final Repositories repositories; + private final DomainClassResolver domainClassResolver; + + /** + * Creates a new {@link JacksonMappingAwareSortTranslator} for the given {@link ObjectMapper}, {@link Repositories} + * and {@link DomainClassResolver}. + * + * @param objectMapper must not be {@literal null}. + * @param repositories must not be {@literal null}. + * @param domainClassResolver must not be {@literal null}. + */ + public JacksonMappingAwareSortTranslator(ObjectMapper objectMapper, Repositories repositories, + DomainClassResolver domainClassResolver) { + + Assert.notNull(objectMapper, "ObjectMapper must not be null!"); + Assert.notNull(repositories, "Repositories must not be null!"); + Assert.notNull(domainClassResolver, "DomainClassResolver must not be null!"); + + this.objectMapper = objectMapper; + this.repositories = repositories; + this.domainClassResolver = domainClassResolver; + } + + /** + * Translates Jackson field names within a {@link Sort} to {@link PersistentProperty} property names. + * + * @param input must not be {@literal null}. + * @param parameter must not be {@literal null}. + * @param webRequest must not be {@literal null}. + * @return a {@link Sort} containing translated property names or {@literal null} the resulting {@link Sort} contains + * no properties. + */ + protected Sort translateMethodParameter(Sort input, MethodParameter parameter, NativeWebRequest webRequest) { + + Assert.notNull(input, "Sort must not be null!"); + Assert.notNull(parameter, "MethodParameter must not be null!"); + Assert.notNull(webRequest, "NativeWebRequest must not be null!"); + + Class domainClass = domainClassResolver.resolve(parameter.getMethod(), webRequest); + PersistentEntity persistentEntity = repositories.getPersistentEntity(domainClass); + MappedProperties mappedProperties = MappedProperties.fromJacksonProperties(persistentEntity, objectMapper); + + return new SortTranslator(mappedProperties).translateSort(input); + } + + /** + * Translates {@link Sort} orders from Jackson-mapped field names to {@link PersistentProperty} names. + * + * @author Mark Paluch + */ + static class SortTranslator { + + private final MappedProperties mappedProperties; + + /** + * Creates a new {@link SortTranslator}. + * + * @param mappedProperties must not be {@literal null}. + */ + public SortTranslator(MappedProperties mappedProperties) { + + Assert.notNull(mappedProperties, "MappedProperties must not be null!"); + + this.mappedProperties = mappedProperties; + } + + /** + * Translates {@link Sort} orders from Jackson-mapped field names to {@link PersistentProperty} names. Properties + * that cannot be resolved are dropped. + * + * @param input must not be {@literal null}. + * @return {@link Sort} with translated field names or {@literal null} if translation dropped all sort fields. + */ + Sort translateSort(Sort input) { + + List filteredOrders = new ArrayList(); + + for (Order order : input) { + + if (mappedProperties.hasPersistentPropertyForField(order.getProperty())) { + PersistentProperty persistentProperty = mappedProperties.getPersistentProperty(order.getProperty()); + Order mappedOrder = new Order(order.getDirection(), persistentProperty.getName(), order.getNullHandling()); + filteredOrders.add(order.isIgnoreCase() ? mappedOrder.ignoreCase() : mappedOrder); + } + } + + if (filteredOrders.isEmpty()) { + return null; + } + + return new Sort(filteredOrders); + } + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappedProperties.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappedProperties.java new file mode 100644 index 000000000..19ca4ae2b --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappedProperties.java @@ -0,0 +1,111 @@ +/* + * Copyright 2016 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 java.util.HashMap; +import java.util.Map; + +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.introspect.BasicClassIntrospector; +import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; +import com.fasterxml.jackson.databind.introspect.ClassIntrospector; + +/** + * Simple value object to capture a mapping of Jackson mapped field names and {@link PersistentProperty} instances. + * + * @author Oliver Gierke + * @author Mark Paluch + */ +class MappedProperties { + + private final Map, String> propertyToFieldName; + private final Map> fieldNameToProperty; + + /** + * Creates a new {@link MappedProperties} instance for the given {@link PersistentEntity} and {@link BeanDescription}. + * + * @param entity must not be {@literal null}. + * @param description must not be {@literal null}. + */ + private MappedProperties(PersistentEntity entity, BeanDescription description) { + + this.propertyToFieldName = new HashMap, String>(); + this.fieldNameToProperty = new HashMap>(); + + for (BeanPropertyDefinition property : description.findProperties()) { + + PersistentProperty persistentProperty = entity.getPersistentProperty(property.getInternalName()); + + propertyToFieldName.put(persistentProperty, property.getName()); + fieldNameToProperty.put(property.getName(), persistentProperty); + } + } + + /** + * Creates {@link MappedProperties} for the given {@link PersistentEntity}. + * + * @param entity must not be {@literal null}. + * @param mapper must not be {@literal null}. + * @return + */ + public static MappedProperties fromJacksonProperties(PersistentEntity entity, ObjectMapper mapper) { + + ClassIntrospector introspector = new BasicClassIntrospector(); + + BeanDescription description = introspector.forDeserialization(mapper.getDeserializationConfig(), + mapper.constructType(entity.getType()), mapper.getDeserializationConfig()); + + return new MappedProperties(entity, description); + } + + /** + * @param property must not be {@literal null} + * @return the mapped name for the {@link PersistentProperty} + */ + public String getMappedName(PersistentProperty property) { + + Assert.notNull(property, "PersistentProperty must not be null!"); + + return propertyToFieldName.get(property); + } + + /** + * @param fieldName must not be empty or {@literal null}. + * @return {@literal true} if the field name resolves to a {@literal PersistentProperty}. + */ + public boolean hasPersistentPropertyForField(String fieldName) { + + Assert.hasText(fieldName, "Field name must not be empty or null!"); + + return fieldNameToProperty.containsKey(fieldName); + } + + /** + * @param fieldName must not be empty or {@literal null}. + * @return + */ + public PersistentProperty getPersistentProperty(String fieldName) { + + Assert.hasText(fieldName, "Field name must not be empty or null!"); + + return fieldNameToProperty.get(fieldName); + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappingAwareDefaultedPageableArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappingAwareDefaultedPageableArgumentResolver.java new file mode 100644 index 000000000..07ea1ee2f --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappingAwareDefaultedPageableArgumentResolver.java @@ -0,0 +1,92 @@ +/* + * Copyright 2016 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 org.springframework.core.MethodParameter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.rest.webmvc.support.DefaultedPageable; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +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 resolve {@link DefaultedPageable} from a + * {@link PageableHandlerMethodArgumentResolver} applying field to property mapping. + *

+ * A resolved {@link DefaultedPageable} is post-processed by applying Jackson field-to-property mapping if it contains a + * {@link Sort} instance. Customized fields are resolved to their property names. Unknown properties are removed from + * {@link Sort}. + * + * @author Mark Paluch + * @since 2.6 + */ +public class MappingAwareDefaultedPageableArgumentResolver implements HandlerMethodArgumentResolver { + + private final PageableHandlerMethodArgumentResolver delegate; + private final JacksonMappingAwareSortTranslator translator; + + /** + * Creates a new {@link MappingAwareDefaultedPageableArgumentResolver} for the given + * {@link JacksonMappingAwareSortTranslator} and {@link PageableHandlerMethodArgumentResolver}. + * + * @param translator must not be {@literal null}. + * @param delegate must not be {@literal null}. + */ + public MappingAwareDefaultedPageableArgumentResolver(JacksonMappingAwareSortTranslator translator, + PageableHandlerMethodArgumentResolver delegate) { + + Assert.notNull(translator, "JacksonMappingSortTranslator must not be null!"); + Assert.notNull(delegate, "PageableHandlerMethodArgumentResolver must not be null!"); + + this.translator = translator; + this.delegate = delegate; + } + + /* + * (non-Javadoc) + * @see org.springframework.web.method.support.HandlerMethodArgumentResolver#supportsParameter(org.springframework.core.MethodParameter) + */ + @Override + public boolean supportsParameter(MethodParameter parameter) { + return DefaultedPageable.class.isAssignableFrom(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 { + + Pageable pageable = delegate.resolveArgument(parameter, mavContainer, webRequest, binderFactory); + + if (pageable == null || pageable.getSort() == null) { + return new DefaultedPageable(pageable, delegate.isFallbackPageable(pageable)); + } + + Sort translated = translator.translateMethodParameter(pageable.getSort(), parameter, webRequest); + pageable = new PageRequest(pageable.getPageNumber(), pageable.getPageSize(), translated); + + return new DefaultedPageable(pageable, delegate.isFallbackPageable(pageable)); + } + +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappingAwarePageableArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappingAwarePageableArgumentResolver.java new file mode 100644 index 000000000..2669ab1e7 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappingAwarePageableArgumentResolver.java @@ -0,0 +1,89 @@ +/* + * Copyright 2016 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 org.springframework.core.MethodParameter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +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 resolve {@link Pageable} from a + * {@link PageableHandlerMethodArgumentResolver} applying field to property mapping. + *

+ * A resolved {@link Pageable} is post-processed by applying Jackson field-to-property mapping if it contains a + * {@link Sort} instance. Customized fields are resolved to their property names. Unknown properties are removed from + * {@link Sort}. + * + * @author Mark Paluch + * @since 2.6 + */ +public class MappingAwarePageableArgumentResolver implements HandlerMethodArgumentResolver { + + private final JacksonMappingAwareSortTranslator translator; + private final PageableHandlerMethodArgumentResolver delegate; + + /** + * Creates a new {@link MappingAwarePageableArgumentResolver} for the given {@link JacksonMappingAwareSortTranslator} + * and {@link PageableHandlerMethodArgumentResolver}. + * + * @param translator must not be {@literal null}. + * @param delegate must not be {@literal null}. + */ + public MappingAwarePageableArgumentResolver(JacksonMappingAwareSortTranslator translator, + PageableHandlerMethodArgumentResolver delegate) { + + Assert.notNull(translator, "JacksonMappingSortTranslator must not be null!"); + Assert.notNull(delegate, "PageableHandlerMethodArgumentResolver must not be null!"); + + this.translator = translator; + this.delegate = delegate; + } + + /* + * (non-Javadoc) + * @see org.springframework.web.method.support.HandlerMethodArgumentResolver#supportsParameter(org.springframework.core.MethodParameter) + */ + @Override + public boolean supportsParameter(MethodParameter parameter) { + return delegate.supportsParameter(parameter); + } + + /* + * (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 Pageable resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + + Pageable pageable = delegate.resolveArgument(parameter, mavContainer, webRequest, binderFactory); + + if (pageable == null || pageable.getSort() == null) { + return null; + } + + Sort translated = translator.translateMethodParameter(pageable.getSort(), parameter, webRequest); + return new PageRequest(pageable.getPageNumber(), pageable.getPageSize(), translated); + } + +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappingAwareSortArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappingAwareSortArgumentResolver.java new file mode 100644 index 000000000..ef99f2ab9 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappingAwareSortArgumentResolver.java @@ -0,0 +1,84 @@ +/* + * Copyright 2016 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 org.springframework.core.MethodParameter; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortHandlerMethodArgumentResolver; +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 resolve {@link Sort} from a {@link SortHandlerMethodArgumentResolver} + * applying field to property mapping. + *

+ * A resolved {@link Sort} is post-processed by applying Jackson field-to-property mapping. Customized fields are + * resolved to their property names. Unknown properties are removed from {@link Sort}. + * + * @author Mark Paluch + * @since 2.6 + */ +public class MappingAwareSortArgumentResolver implements HandlerMethodArgumentResolver { + + private final JacksonMappingAwareSortTranslator translator; + private final SortHandlerMethodArgumentResolver delegate; + + /** + * Creates a new {@link MappingAwareSortArgumentResolver} for the given {@link JacksonMappingAwareSortTranslator} and + * {@link SortHandlerMethodArgumentResolver}. + * + * @param translator must not be {@literal null}. + * @param delegate must not be {@literal null}. + */ + public MappingAwareSortArgumentResolver(JacksonMappingAwareSortTranslator translator, + SortHandlerMethodArgumentResolver delegate) { + + Assert.notNull(translator, "JacksonMappingSortTranslator must not be null!"); + Assert.notNull(delegate, "PageableHandlerMethodArgumentResolver must not be null!"); + + this.translator = translator; + this.delegate = delegate; + } + + /* + * (non-Javadoc) + * @see org.springframework.web.method.support.HandlerMethodArgumentResolver#supportsParameter(org.springframework.core.MethodParameter) + */ + @Override + public boolean supportsParameter(MethodParameter parameter) { + return delegate.supportsParameter(parameter); + } + + /* + * (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 Sort resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + + Sort sort = delegate.resolveArgument(parameter, mavContainer, webRequest, binderFactory); + + if (sort == null) { + return null; + } + + return translator.translateMethodParameter(sort, parameter, webRequest); + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/DomainClassResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/DomainClassResolver.java new file mode 100644 index 000000000..227242476 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/DomainClassResolver.java @@ -0,0 +1,101 @@ +/* + * Copyright 2016 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 java.lang.reflect.Method; + +import org.springframework.data.repository.support.Repositories; +import org.springframework.data.rest.core.mapping.ResourceMappings; +import org.springframework.data.rest.core.mapping.ResourceMetadata; +import org.springframework.data.rest.webmvc.BaseUri; +import org.springframework.data.rest.webmvc.util.UriUtils; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.NativeWebRequest; + +/** + * Resolves a domain class from a web request. Domain class resolution is only available for {@link NativeWebRequest web + * requests} related to mapped and exported {@link Repositories}. + * + * @author Mark Paluch + * @since 2.6 + */ +public class DomainClassResolver { + + private final Repositories repositories; + private final ResourceMappings mappings; + private final BaseUri baseUri; + + /** + * Creates a new {@link DomainClassResolver} for the given {@link Repositories} and {@link ResourceMappings}. + * + * @param repositories must not be {@literal null}. + * @param mappings must not be {@literal null}. + * @param baseUri must not be {@literal null}. + */ + private DomainClassResolver(Repositories repositories, ResourceMappings mappings, BaseUri baseUri) { + + Assert.notNull(repositories, "Repositories must not be null!"); + Assert.notNull(mappings, "ResourceMappings must not be null!"); + Assert.notNull(baseUri, "BaseUri must not be null!"); + + this.repositories = repositories; + this.mappings = mappings; + this.baseUri = baseUri; + } + + /** + * Creates a new {@link DomainClassResolver} for the given {@link Repositories} and {@link ResourceMappings}. + * + * @param repositories must not be {@literal null}. + * @param mappings must not be {@literal null}. + * @param baseUri must not be {@literal null}. + */ + public static DomainClassResolver create(Repositories repositories, ResourceMappings mappings, BaseUri baseUri) { + return new DomainClassResolver(repositories, mappings, baseUri); + } + + /** + * Resolves a domain class that is associated with the {@link NativeWebRequest} + * + * @param method must not be {@literal null}. + * @param webRequest must not be {@literal null}. + * @return domain type that is associated with this request. + * @throws IllegalArgumentException if there's no repository key associated or no domain type can be resolved. + */ + public Class resolve(Method method, NativeWebRequest webRequest) { + + Assert.notNull(method, "Method must not be null!"); + Assert.notNull(webRequest, "NativeWebRequest must not be null!"); + + String lookupPath = baseUri.getRepositoryLookupPath(webRequest); + String repositoryKey = UriUtils.findMappingVariable("repository", method, lookupPath); + + if (!StringUtils.hasText(repositoryKey)) { + throw new IllegalArgumentException(String.format("Could not determine a repository key from %s.", lookupPath)); + } + + for (Class domainType : repositories) { + ResourceMetadata mapping = mappings.getMetadataFor(domainType); + if (mapping.getPath().matches(repositoryKey) && mapping.isExported()) { + return domainType; + } + } + + throw new IllegalArgumentException( + String.format("Could not resolve an exported domain type for %s.", repositoryKey)); + } +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Book.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Book.java index 86bf1680b..f136be7ba 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Book.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Book.java @@ -26,8 +26,11 @@ import javax.persistence.ManyToMany; import org.springframework.data.rest.core.annotation.RestResource; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * @author Oliver Gierke + * @author Mark Paluch */ @Entity public class Book { @@ -38,14 +41,18 @@ public class Book { @ManyToMany(cascade = { CascadeType.MERGE })// @RestResource(path = "creators") public Set authors; + @JsonProperty("sales") + public long soldUnits; + public String title; protected Book() {} - public Book(String isbn, String title, Iterable authors) { + public Book(String isbn, String title, long soldUnits, Iterable authors) { this.isbn = isbn; this.title = title; + this.soldUnits = soldUnits; this.authors = new HashSet(); diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/BookRepository.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/BookRepository.java index 43d277a80..eb419f787 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/BookRepository.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/BookRepository.java @@ -17,6 +17,8 @@ package org.springframework.data.rest.webmvc.jpa; import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; @@ -26,6 +28,7 @@ import org.springframework.data.rest.core.annotation.RestResource; /** * @author Oliver Gierke + * @author Mark Paluch */ @RepositoryRestResource(excerptProjection = BookExcerpt.class) public interface BookRepository extends CrudRepository { @@ -35,4 +38,8 @@ public interface BookRepository extends CrudRepository { @Query("select b from Book b where :author member of b.authors") List findByAuthorsContains(@Param("author") Author author); + + @RestResource(rel = "find-spring-books-sorted") + @Query("select b from Book b where b.title like 'Spring%'") + Page findByTitleIsLike(Pageable pageable); } 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 35ca5ce37..b0f7400a1 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 @@ -20,6 +20,7 @@ import static org.junit.Assert.*; import static org.springframework.data.rest.webmvc.util.TestUtils.*; import static org.springframework.http.HttpHeaders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import net.minidev.json.JSONArray; @@ -57,6 +58,7 @@ import com.jayway.jsonpath.JsonPath; * * @author Oliver Gierke * @author Greg Turnquist + * @author Mark Paluch */ @Transactional @ContextConfiguration(classes = JpaRepositoryConfig.class) @@ -546,7 +548,7 @@ public class JpaWebTests extends CommonWebTests { * @see DATAREST-384 */ @Test - public void execturesSearchThatTakesASort() throws Exception { + public void exectuesSearchThatTakesASort() throws Exception { Link booksLink = client.discoverUnique("books"); Link searchLink = client.discoverUnique(booksLink, "search"); @@ -568,6 +570,57 @@ public class JpaWebTests extends CommonWebTests { andExpect(client.hasLinkWithRel("self")); } + /** + * @see DATAREST-883 + */ + @Test + public void exectuesSearchThatTakesAMappedSortProperty() throws Exception { + + Link booksLink = client.discoverUnique("books"); + Link searchLink = client.discoverUnique(booksLink, "search"); + Link findBySortedLink = client.discoverUnique(searchLink, "find-by-sorted"); + + // Assert sort options advertised + assertThat(findBySortedLink.isTemplated(), is(true)); + assertThat(findBySortedLink.getVariableNames(), hasItems("sort", "projection")); + + // Assert results returned as specified + client.follow(findBySortedLink.expand("sales,desc")).// + andExpect(jsonPath("$._embedded.books[0].title").value("Spring Data (Second Edition)")).// + andExpect(jsonPath("$._embedded.books[1].title").value("Spring Data")).// + andExpect(client.hasLinkWithRel("self")); + + client.follow(findBySortedLink.expand("sales,asc")).// + andExpect(jsonPath("$._embedded.books[0].title").value("Spring Data")).// + andExpect(jsonPath("$._embedded.books[1].title").value("Spring Data (Second Edition)")).// + andExpect(client.hasLinkWithRel("self")); + } + + /** + * @see DATAREST-883 + */ + @Test + public void exectuesCustomQuerySearchThatTakesAMappedSortProperty() throws Exception { + + Link booksLink = client.discoverUnique("books"); + Link searchLink = client.discoverUnique(booksLink, "search"); + Link findByLink = client.discoverUnique(searchLink, "find-spring-books-sorted"); + + // Assert sort options advertised + assertThat(findByLink.isTemplated(), is(true)); + + // Assert results returned as specified + client.follow(findByLink.expand("0", "10", "sales,desc")).// + andExpect(jsonPath("$._embedded.books[0].title").value("Spring Data (Second Edition)")).// + andExpect(jsonPath("$._embedded.books[1].title").value("Spring Data")).// + andExpect(client.hasLinkWithRel("self")); + + client.follow(findByLink.expand("0", "10", "unknown,asc,sales,asc")).// + andExpect(jsonPath("$._embedded.books[0].title").value("Spring Data")).// + andExpect(jsonPath("$._embedded.books[1].title").value("Spring Data (Second Edition)")).// + andExpect(client.hasLinkWithRel("self")); + } + /** * @see DATAREST-160 */ diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/TestDataPopulator.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/TestDataPopulator.java index f617922d5..c7d0fe48f 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/TestDataPopulator.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/TestDataPopulator.java @@ -53,8 +53,8 @@ public class TestDataPopulator { Iterable authors = this.authors.save(Arrays.asList(ollie, mark, michael, david, john, thomas)); - books.save(new Book("1449323952", "Spring Data", authors)); - books.save(new Book("1449323953", "Spring Data (Second Edition)", authors)); + books.save(new Book("1449323952", "Spring Data", 1000, authors)); + books.save(new Book("1449323953", "Spring Data (Second Edition)", 2000, authors)); } private void populateOrders() { diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/SortTranslatorUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/SortTranslatorUnitTests.java new file mode 100644 index 000000000..345043d4a --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/SortTranslatorUnitTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2016 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.Matchers.*; +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Unit tests for {@link JacksonMappingAwareSortTranslator.SortTranslator}. + * + * @author Mark Paluch + * @soundtrack dkn - Out Of This World (original version) + */ +public class SortTranslatorUnitTests { + + private MongoMappingContext mappingContext; + + @Before + public void setUp() { + + mappingContext = new MongoMappingContext(); + mappingContext.getPersistentEntity(Plain.class); + mappingContext.getPersistentEntity(WithJsonProperty.class); + } + + /** + * @see DATAREST-883 + */ + @Test + public void shouldMapKnownProperties() { + + MappedProperties mappedProperties = MappedProperties + .fromJacksonProperties(mappingContext.getPersistentEntity(Plain.class), new ObjectMapper()); + Sort translatedSort = new JacksonMappingAwareSortTranslator.SortTranslator(mappedProperties) + .translateSort(new Sort("hello", "name")); + + assertThat(translatedSort.getOrderFor("hello"), is(nullValue())); + assertThat(translatedSort.getOrderFor("name"), is(notNullValue())); + } + + /** + * @see DATAREST-883 + */ + @Test + public void returnsNullSortIfNoPropertiesMatch() { + + MappedProperties mappedProperties = MappedProperties + .fromJacksonProperties(mappingContext.getPersistentEntity(Plain.class), new ObjectMapper()); + Sort translatedSort = new JacksonMappingAwareSortTranslator.SortTranslator(mappedProperties) + .translateSort(new Sort("hello", "world")); + + assertThat(translatedSort, is(nullValue())); + } + + /** + * @see DATAREST-883 + */ + @Test + public void shouldMapKnownPropertiesWithJsonProperty() { + + MappedProperties mappedProperties = MappedProperties + .fromJacksonProperties(mappingContext.getPersistentEntity(WithJsonProperty.class), new ObjectMapper()); + Sort translatedSort = new JacksonMappingAwareSortTranslator.SortTranslator(mappedProperties) + .translateSort(new Sort("hello", "foo")); + + assertThat(translatedSort.getOrderFor("hello"), is(nullValue())); + assertThat(translatedSort.getOrderFor("name"), is(notNullValue())); + } + + /** + * @see DATAREST-883 + */ + @Test + public void shouldJacksonFieldNameForMapping() { + + MappedProperties mappedProperties = MappedProperties + .fromJacksonProperties(mappingContext.getPersistentEntity(WithJsonProperty.class), new ObjectMapper()); + Sort translatedSort = new JacksonMappingAwareSortTranslator.SortTranslator(mappedProperties) + .translateSort(new Sort("name")); + + assertThat(translatedSort, is(nullValue())); + } + + static class Plain { + public String name; + } + + static class WithJsonProperty { + public @JsonProperty("foo") String name; + } +}