From 357bb7a28bab80811024a3d2f2fe5d0c20705f0f Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 28 Sep 2016 11:06:04 +0200 Subject: [PATCH] DATAREST-910 - Support nested Sort properties. We now support nested Sort properties considering Jackson mapping. Sort translation is optional and skipped if the domain class is not resolvable. Translation in the scope of a domain class maps property paths to apply sorting using embedded properties. A sort string `nested-name` resolves to a property path `anotherWrap.embedded.name`. class Aggregate { @JsonUnwrapped public UnwrapEmbedded anotherWrap; } class UnwrapEmbedded { @JsonUnwrapped(prefix = "nested-") public Embedded embedded; } class Embedded { public String name; } Original pull request: #232. --- .../rest/webmvc/jpa/JpaRepositoryConfig.java | 15 +- .../data/rest/webmvc/jpa/JpaWebTests.java | 10 + .../RepositoryRestMvcConfiguration.java | 2 +- .../JacksonMappingAwareSortTranslator.java | 181 ++++++++++++-- .../rest/webmvc/json/WrappedProperties.java | 229 ++++++++++++++++++ .../webmvc/support/DomainClassResolver.java | 18 +- .../data/rest/webmvc/util/UriUtils.java | 22 +- .../webmvc/json/SortTranslatorUnitTests.java | 146 +++++++++-- .../json/WrappedPropertiesUnitTests.java | 207 ++++++++++++++++ .../rest/webmvc/util/UriUtilsUnitTests.java | 72 ++++++ 10 files changed, 861 insertions(+), 41 deletions(-) create mode 100644 spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/WrappedProperties.java create mode 100644 spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/WrappedPropertiesUnitTests.java create mode 100644 spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/util/UriUtilsUnitTests.java diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaRepositoryConfig.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaRepositoryConfig.java index c25756e13..970598535 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaRepositoryConfig.java +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaRepositoryConfig.java @@ -17,8 +17,12 @@ package org.springframework.data.rest.webmvc.jpa; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.rest.webmvc.BasePathAwareController; +import org.springframework.data.rest.webmvc.RepositoryRestController; +import org.springframework.data.rest.webmvc.support.DefaultedPageable; import org.springframework.http.MediaType; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.web.bind.annotation.PathVariable; @@ -30,6 +34,7 @@ import org.springframework.web.bind.annotation.RequestMethod; * * @author Jon Brisbin * @author Oliver Gierke + * @author Mark Paluch */ @Configuration @EnableJpaRepositories @@ -50,6 +55,14 @@ public class JpaRepositoryConfig extends JpaInfrastructureConfig { static class BooksHtmlController { @RequestMapping(value = "/books/{id}", method = RequestMethod.GET, produces = MediaType.TEXT_HTML_VALUE) - void person(@PathVariable String id) {} + void someMethod(@PathVariable String id) {} + } + + @RepositoryRestController + @RequestMapping("orders") + static class OrdersJsonController { + + @RequestMapping(value = "/search/sort", method = RequestMethod.POST, produces = "application/hal+json") + void someMethodWithArgs(Sort sort, Pageable pageable, DefaultedPageable defaultedPageable) {} } } diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java index 37fe88a57..c4d5e1600 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java @@ -702,6 +702,16 @@ public class JpaWebTests extends CommonWebTests { andExpect(client.hasLinkWithRel("self")); } + /** + * @see DATAREST-910 + */ + @Test + public void callUnmappedCustomRepositoryController() throws Exception { + + mvc.perform(post("/orders/search/sort")).andExpect(status().isOk()); + mvc.perform(post("/orders/search/sort?sort=type&page=1&size=10")).andExpect(status().isOk()); + } + private List preparePersonResources(Person primary, Person... persons) throws Exception { Link peopleLink = client.discoverUnique("people"); 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 2cd4f8e26..ff2381371 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 @@ -805,7 +805,7 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon PageableHandlerMethodArgumentResolver pageableResolver = pageableResolver(); JacksonMappingAwareSortTranslator sortTranslator = new JacksonMappingAwareSortTranslator(objectMapper(), - repositories(), DomainClassResolver.of(repositories(), resourceMappings(), baseUri())); + repositories(), DomainClassResolver.of(repositories(), resourceMappings(), baseUri()), persistentEntities()); HandlerMethodArgumentResolver sortResolver = new MappingAwareSortArgumentResolver(sortTranslator, sortResolver()); HandlerMethodArgumentResolver jacksonPageableResolver = new MappingAwarePageableArgumentResolver(sortTranslator, 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 index b1e78ba22..a55634da5 100644 --- 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 @@ -19,27 +19,32 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; 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.mapping.context.PersistentEntities; import org.springframework.data.repository.support.Repositories; import org.springframework.data.rest.webmvc.support.DomainClassResolver; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; 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. + * translated to {@link PersistentProperty} names. Domain class is looked up by resolving request URLs to mapped + * repositories. {@link Sort} translation is skipped if a domain class cannot be resolved. * * @author Mark Paluch - * @since 2.6, 2.5.3 + * @since 2.6 */ @RequiredArgsConstructor public class JacksonMappingAwareSortTranslator { @@ -47,10 +52,11 @@ public class JacksonMappingAwareSortTranslator { private final @NonNull ObjectMapper objectMapper; private final @NonNull Repositories repositories; private final @NonNull DomainClassResolver domainClassResolver; + private final @NonNull PersistentEntities persistentEntities; /** * 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}. @@ -64,10 +70,14 @@ public class JacksonMappingAwareSortTranslator { 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); + if (domainClass != null) { + + PersistentEntity persistentEntity = repositories.getPersistentEntity(domainClass); + return new SortTranslator(persistentEntities, objectMapper).translateSort(input, persistentEntity); + } + + return input; } /** @@ -75,35 +85,178 @@ public class JacksonMappingAwareSortTranslator { * * @author Mark Paluch * @author Oliver Gierke - * @since 2.6, 2.5.3 + * @since 2.6 */ @RequiredArgsConstructor - static class SortTranslator { + public static class SortTranslator { - private final @NonNull MappedProperties mappedProperties; + private static final String DELIMITERS = "_\\."; + private static final String ALL_UPPERCASE = "[A-Z0-9._$]+"; + + private static final Pattern SPLITTER = Pattern.compile("(?:[%s]?([%s]*?[^%s]+))".replaceAll("%s", DELIMITERS)); + + private final @NonNull PersistentEntities persistentEntities; + private final @NonNull ObjectMapper objectMapper; /** * 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}. + * @param rootEntity 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) { + public Sort translateSort(Sort input, PersistentEntity rootEntity) { + + Assert.notNull(input, "Sort must not be null!"); + Assert.notNull(rootEntity, "PersistentEntity must not be null!"); List filteredOrders = new ArrayList(); for (Order order : input) { - if (mappedProperties.hasPersistentPropertyForField(order.getProperty())) { + List iteratorSource = new ArrayList(); + Matcher matcher = SPLITTER.matcher("_" + order.getProperty()); - PersistentProperty persistentProperty = mappedProperties.getPersistentProperty(order.getProperty()); - Order mappedOrder = new Order(order.getDirection(), persistentProperty.getName(), order.getNullHandling()); + while (matcher.find()) { + iteratorSource.add(matcher.group(1)); + } + + String mappedPropertyPath = getMappedPropertyPath(rootEntity, iteratorSource); + + if (mappedPropertyPath != null) { + + Order mappedOrder = new Order(order.getDirection(), mappedPropertyPath, order.getNullHandling()); filteredOrders.add(order.isIgnoreCase() ? mappedOrder.ignoreCase() : mappedOrder); } } return filteredOrders.isEmpty() ? null : new Sort(filteredOrders); } + + private String getMappedPropertyPath(PersistentEntity rootEntity, List iteratorSource) { + + List persistentPropertyPath = mapPropertyPath(rootEntity, iteratorSource); + + if (persistentPropertyPath.isEmpty()) { + return null; + } + + return StringUtils.collectionToDelimitedString(persistentPropertyPath, "."); + } + + private List mapPropertyPath(PersistentEntity rootEntity, List iteratorSource) { + + List persistentPropertyPath = new ArrayList(iteratorSource.size()); + + TypedSegment typedSegment = TypedSegment.create(persistentEntities, objectMapper, rootEntity); + + for (String field : iteratorSource) { + + String fieldName = field.matches(ALL_UPPERCASE) ? field : StringUtils.uncapitalize(field); + + if (!typedSegment.hasPersistentPropertyForField(fieldName)) { + return Collections.emptyList(); + } + + List> persistentProperties = typedSegment.getPersistentProperties(fieldName); + + for (PersistentProperty persistentProperty : persistentProperties) { + + if (persistentProperty.isAssociation()) { + return Collections.emptyList(); + } + + persistentPropertyPath.add(persistentProperty.getName()); + } + + typedSegment = typedSegment.next(persistentProperties.get(persistentProperties.size() - 1)); + } + + return persistentPropertyPath; + } + } + + /** + * A typed segment inside a Jackson property path. {@link TypedSegment} represents a segment in JSON field path to + * {@link PersistentProperty} mapping. + * + * @author Mark Paluch + */ + static class TypedSegment { + + private final PersistentEntities persistentEntities; + private final ObjectMapper objectMapper; + private final PersistentEntity currentType; + private final MappedProperties currentProperties; + private final WrappedProperties currentWrappedProperties; + + private TypedSegment(TypedSegment previous, PersistentEntity persistentEntity) { + this(previous.persistentEntities, previous.objectMapper, persistentEntity); + } + + private TypedSegment(PersistentEntities persistentEntities, ObjectMapper objectMapper, + PersistentEntity persistentEntity) { + + this.persistentEntities = persistentEntities; + this.objectMapper = objectMapper; + this.currentType = persistentEntity; + + if (persistentEntity != null) { + this.currentProperties = MappedProperties.fromJacksonProperties(currentType, objectMapper); + this.currentWrappedProperties = WrappedProperties.fromJacksonProperties(persistentEntities, currentType, + objectMapper); + } else { + this.currentProperties = null; + this.currentWrappedProperties = null; + } + } + + /** + * Creates the initial {@link TypedSegment} given {@link PersistentEntities}, {@link ObjectMapper} and + * {@link PersistentEntity}. + * + * @param persistentEntities must not be {@literal null}. + * @param objectMapper must not be {@literal null}. + * @param rootEntity the initial entity to start mapping from, must not be {@literal null}. + * @return + */ + public static TypedSegment create(PersistentEntities persistentEntities, ObjectMapper objectMapper, + PersistentEntity rootEntity) { + + Assert.notNull(persistentEntities, "PersistentEntities must not be null!"); + Assert.notNull(objectMapper, "ObjectMapper must not be null!"); + Assert.notNull(rootEntity, "PersistentEntity must not be null!"); + + return new TypedSegment(persistentEntities, objectMapper, rootEntity); + } + + /** + * Continue mapping by providing the next {@link PersistentProperty}. + * + * @param persistentProperty must not be {@literal null}. + * @return + */ + public TypedSegment next(PersistentProperty persistentProperty) { + + Assert.notNull(persistentProperty, "PersistentProperty must not be null!"); + + PersistentEntity persistentEntity = persistentEntities.getPersistentEntity(persistentProperty.getType()); + return new TypedSegment(this, persistentEntity); + } + + protected boolean hasPersistentPropertyForField(String fieldName) { + return currentType != null && (currentProperties.hasPersistentPropertyForField(fieldName) + || currentWrappedProperties.hasPersistentPropertiesForField(fieldName)); + } + + protected List> getPersistentProperties(String fieldName) { + + if (currentWrappedProperties.hasPersistentPropertiesForField(fieldName)) { + return currentWrappedProperties.getPersistentProperties(fieldName); + } + + return Collections.singletonList(currentProperties.getPersistentProperty(fieldName)); + } } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/WrappedProperties.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/WrappedProperties.java new file mode 100644 index 000000000..a95da27ba --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/WrappedProperties.java @@ -0,0 +1,229 @@ +/* + * 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 lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.context.PersistentEntities; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.databind.AnnotationIntrospector; +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.introspect.AnnotatedMember; +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.introspect.JacksonAnnotationIntrospector; +import com.fasterxml.jackson.databind.util.NameTransformer; + +/** + * Simple value object to capture a mapping of Jackson mapped field names using + * {@link com.fasterxml.jackson.annotation.JsonUnwrapped} and {@link PersistentProperty} instances. + * + * @author Mark Paluch + * @since 2.6 + */ +class WrappedProperties { + + private static final ClassIntrospector INTROSPECTOR = new BasicClassIntrospector(); + private static final AnnotationIntrospector ANNOTATION_INTROSPECTOR = new JacksonAnnotationIntrospector(); + + private final Map>> fieldNameToProperties; + + /** + * Creates a new {@link WrappedProperties} instance for the given {@code fieldNameToProperties}. + * + * @param fieldNameToProperties must not be {@literal null}. + */ + private WrappedProperties(Map>> fieldNameToProperties) { + this.fieldNameToProperties = new HashMap>>(fieldNameToProperties); + } + + /** + * Creates {@link WrappedProperties} for the given {@link PersistentEntities} and {@link PersistentEntity}. + * + * @param persistentEntities must not be {@literal null}. + * @param entity must not be {@literal null}. + * @param mapper must not be {@literal null}. + * @return + */ + public static WrappedProperties fromJacksonProperties(PersistentEntities persistentEntities, + PersistentEntity entity, ObjectMapper mapper) { + + Assert.notNull(entity, "PersistentEntity must not be null!"); + + JacksonUnwrappedPropertiesResolver resolver = new JacksonUnwrappedPropertiesResolver(persistentEntities, mapper); + return new WrappedProperties(resolver.findUnwrappedPropertyPaths(entity.getType())); + } + + /** + * @param fieldName must not be empty or {@literal null}. + * @return {@literal true} if the field name resolves to a {@literal PersistentProperty}. + */ + public boolean hasPersistentPropertiesForField(String fieldName) { + + Assert.hasText(fieldName, "Field name must not be null or empty!"); + return fieldNameToProperties.containsKey(fieldName); + } + + /** + * @param fieldName must not be empty or {@literal null}. + * @return + */ + public List> getPersistentProperties(String fieldName) { + + Assert.hasText(fieldName, "Field name must not be null or empty!"); + + return hasPersistentPropertiesForField(fieldName) + ? Collections.unmodifiableList(fieldNameToProperties.get(fieldName)) + : Collections.> emptyList(); + } + + /** + * This class resolves {@code @JsonUnwrapped} field names to a list of involved {@link PersistentProperty properties}. + * + * @author Mark Paluch + */ + @RequiredArgsConstructor + static class JacksonUnwrappedPropertiesResolver { + + final @NonNull PersistentEntities persistentEntities; + final @NonNull ObjectMapper mapper; + + /** + * Resolve {@code @JsonUnwrapped} field names to a list of involved {@link PersistentProperty properties}. + * + * @param type must not be {@literal null}. + * @return + */ + public Map>> findUnwrappedPropertyPaths(Class type) { + + Assert.notNull(type, "Type must not be null!"); + + return findUnwrappedPropertyPaths(type, NameTransformer.NOP, false); + } + + private Map>> findUnwrappedPropertyPaths(Class type, + NameTransformer nameTransformer, boolean considerRegularProperties) { + + PersistentEntity entity = persistentEntities.getPersistentEntity(type); + + if (entity == null) { + return Collections.emptyMap(); + } + + Map>> mapping = new HashMap>>(); + + for (BeanPropertyDefinition property : getMappedProperties(entity)) { + + AnnotatedMember annotatedMember = findAnnotatedMember(property); + + PersistentProperty persistentProperty = entity.getPersistentProperty(property.getInternalName()); + + if (isJsonUnwrapped(annotatedMember)) { + mapping.putAll(findUnwrappedPropertyPaths(nameTransformer, annotatedMember, persistentProperty)); + } else if (considerRegularProperties) { + mapping.put(nameTransformer.transform(property.getName()), + Collections.> singletonList(persistentProperty)); + } + } + + return mapping; + } + + private Map>> findUnwrappedPropertyPaths(NameTransformer nameTransformer, + AnnotatedMember annotatedMember, PersistentProperty persistentProperty) { + + Map>> mapping = new HashMap>>(); + + NameTransformer propertyNameTransformer = NameTransformer.chainedTransformer(nameTransformer, + ANNOTATION_INTROSPECTOR.findUnwrappingNameTransformer(annotatedMember)); + + Map>> nestedProperties = findUnwrappedPropertyPaths( + annotatedMember.getRawType(), propertyNameTransformer, true); + + for (String key : nestedProperties.keySet()) { + + List> persistentProperties = new ArrayList>(); + + persistentProperties.add(persistentProperty); + persistentProperties.addAll(nestedProperties.get(key)); + + mapping.put(key, persistentProperties); + } + + return mapping; + } + + private List getMappedProperties(PersistentEntity entity) { + + List properties = getBeanDescription(entity.getType()).findProperties(); + List withInternalName = new ArrayList(properties.size()); + + for (BeanPropertyDefinition property : properties) { + + AnnotatedMember annotatedMember = findAnnotatedMember(property); + + if (annotatedMember == null || entity.getPersistentProperty(property.getInternalName()) == null) { + continue; + } + + withInternalName.add(property); + } + + return withInternalName; + } + + private AnnotatedMember findAnnotatedMember(BeanPropertyDefinition property) { + + if (property.getPrimaryMember() != null) { + return property.getPrimaryMember(); + } + + if (property.getGetter() != null) { + return property.getGetter(); + } + + if (property.getSetter() != null) { + return property.getSetter(); + } + + return null; + } + + private static boolean isJsonUnwrapped(AnnotatedMember primaryMember) { + return primaryMember.hasAnnotation(JsonUnwrapped.class) + && primaryMember.getAnnotation(JsonUnwrapped.class).enabled(); + } + + private BeanDescription getBeanDescription(Class type) { + return INTROSPECTOR.forDeserialization(mapper.getDeserializationConfig(), mapper.constructType(type), + mapper.getDeserializationConfig()); + } + } +} 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 index ecaa0ba20..b2c098405 100644 --- 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 @@ -19,6 +19,7 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; import java.lang.reflect.Method; +import java.util.List; import org.springframework.data.repository.support.Repositories; import org.springframework.data.rest.core.mapping.ResourceMappings; @@ -35,7 +36,7 @@ import org.springframework.web.context.request.NativeWebRequest; * * @author Mark Paluch * @author Oliver Gierke - * @since 2.6, 2.5.3 + * @since 2.6 */ @RequiredArgsConstructor(staticName = "of") public class DomainClassResolver { @@ -49,8 +50,7 @@ public class DomainClassResolver { * * @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. + * @return domain type that is associated with this request or {@literal null} if no domain class can be resolved. */ public Class resolve(Method method, NativeWebRequest webRequest) { @@ -61,7 +61,14 @@ public class DomainClassResolver { 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)); + List pathSegments = UriUtils.getPathSegments(method); + if (!pathSegments.isEmpty()) { + repositoryKey = pathSegments.get(0); + } + } + + if (!StringUtils.hasText(repositoryKey)) { + return null; } for (Class domainType : repositories) { @@ -73,7 +80,6 @@ public class DomainClassResolver { } } - throw new IllegalArgumentException( - String.format("Could not resolve an exported domain type for %s.", repositoryKey)); + return null; } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/util/UriUtils.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/util/UriUtils.java index dee6e86a8..a0004565f 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/util/UriUtils.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/util/UriUtils.java @@ -16,16 +16,19 @@ package org.springframework.data.rest.webmvc.util; import java.lang.reflect.Method; +import java.util.List; import java.util.Map; import org.springframework.hateoas.core.AnnotationMappingDiscoverer; import org.springframework.util.Assert; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.util.UriComponentsBuilder; /** * Utility methods to work with requests and URIs. * * @author Oliver Gierke + * @author Mark Paluch */ public abstract class UriUtils { @@ -37,8 +40,8 @@ public abstract class UriUtils { * Returns the value for the mapping variable with the given name. * * @param variable must not be {@literal null} or empty. - * @param parameter - * @param request + * @param method must not be {@literal null}. + * @param lookupPath * @return */ public static String findMappingVariable(String variable, Method method, String lookupPath) { @@ -57,4 +60,19 @@ public abstract class UriUtils { return null; } + + /** + * Returns the mapping path segments request mapping. + * + * @param method must not be {@literal null}. + * @return + */ + public static List getPathSegments(Method method) { + + Assert.notNull(method, "Method must not be null!"); + + String mapping = DISCOVERER.getMapping(method.getDeclaringClass(), method); + + return UriComponentsBuilder.fromPath(mapping).build().getPathSegments(); + } } 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 index 333dd6216..a961ddb53 100644 --- 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 @@ -18,12 +18,19 @@ package org.springframework.data.rest.webmvc.json; import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; +import java.util.Collections; +import java.util.List; + import org.junit.Before; import org.junit.Test; +import org.springframework.data.annotation.Reference; import org.springframework.data.domain.Sort; import org.springframework.data.keyvalue.core.mapping.context.KeyValueMappingContext; +import org.springframework.data.mapping.context.PersistentEntities; +import org.springframework.data.rest.webmvc.json.JacksonMappingAwareSortTranslator.SortTranslator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.databind.ObjectMapper; /** @@ -35,7 +42,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; */ public class SortTranslatorUnitTests { + private ObjectMapper objectMapper = new ObjectMapper(); private KeyValueMappingContext mappingContext; + private PersistentEntities persistentEntities; + private SortTranslator sortTranslator; @Before public void setUp() { @@ -43,6 +53,11 @@ public class SortTranslatorUnitTests { mappingContext = new KeyValueMappingContext(); mappingContext.getPersistentEntity(Plain.class); mappingContext.getPersistentEntity(WithJsonProperty.class); + mappingContext.getPersistentEntity(UnwrapEmbedded.class); + mappingContext.getPersistentEntity(MultiUnwrapped.class); + + persistentEntities = new PersistentEntities(Collections.singleton(mappingContext)); + sortTranslator = new SortTranslator(persistentEntities, objectMapper); } /** @@ -51,10 +66,8 @@ public class SortTranslatorUnitTests { @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")); + Sort translatedSort = sortTranslator.translateSort(new Sort("hello", "name"), + mappingContext.getPersistentEntity(Plain.class)); assertThat(translatedSort.getOrderFor("hello"), is(nullValue())); assertThat(translatedSort.getOrderFor("name"), is(notNullValue())); @@ -66,10 +79,8 @@ public class SortTranslatorUnitTests { @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")); + Sort translatedSort = sortTranslator.translateSort(new Sort("hello", "world"), + mappingContext.getPersistentEntity(Plain.class)); assertThat(translatedSort, is(nullValue())); } @@ -80,10 +91,8 @@ public class SortTranslatorUnitTests { @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")); + Sort translatedSort = sortTranslator.translateSort(new Sort("hello", "foo"), + mappingContext.getPersistentEntity(WithJsonProperty.class)); assertThat(translatedSort.getOrderFor("hello"), is(nullValue())); assertThat(translatedSort.getOrderFor("name"), is(notNullValue())); @@ -95,19 +104,122 @@ public class SortTranslatorUnitTests { @Test public void shouldJacksonFieldNameForMapping() { - MappedProperties mappedProperties = MappedProperties - .fromJacksonProperties(mappingContext.getPersistentEntity(WithJsonProperty.class), new ObjectMapper()); - Sort translatedSort = new JacksonMappingAwareSortTranslator.SortTranslator(mappedProperties) - .translateSort(new Sort("name")); + Sort translatedSort = sortTranslator.translateSort(new Sort("name"), + mappingContext.getPersistentEntity(WithJsonProperty.class)); assertThat(translatedSort, is(nullValue())); } + /** + * @see DATAREST-910 + */ + @Test + public void shouldMapKnownNestedProperties() { + + Sort translatedSort = sortTranslator.translateSort( + new Sort("embedded.name", "embedded.collection", "embedded.someInterface"), + mappingContext.getPersistentEntity(Plain.class)); + + assertThat(translatedSort.getOrderFor("embedded.name"), is(notNullValue())); + assertThat(translatedSort.getOrderFor("embedded.collection"), is(notNullValue())); + assertThat(translatedSort.getOrderFor("embedded.someInterface"), is(notNullValue())); + } + + /** + * @see DATAREST-910 + */ + @Test + public void shouldSkipWrongNestedProperties() { + + Sort translatedSort = sortTranslator.translateSort(new Sort("embedded.unknown"), + mappingContext.getPersistentEntity(Plain.class)); + + assertThat(translatedSort, is(nullValue())); + } + + /** + * @see DATAREST-910 + */ + @Test + public void shouldSkipKnownAssociationProperties() { + + Sort translatedSort = sortTranslator.translateSort(new Sort("refEmbedded.name"), + mappingContext.getPersistentEntity(Plain.class)); + + assertThat(translatedSort, is(nullValue())); + } + + /** + * @see DATAREST-910 + */ + @Test + public void shouldJacksonFieldNameForNestedFieldMapping() { + + Sort translatedSort = sortTranslator.translateSort(new Sort("em.foo"), + mappingContext.getPersistentEntity(WithJsonProperty.class)); + + assertThat(translatedSort.getOrderFor("embeddedWithJsonProperty.bar"), is(notNullValue())); + } + + /** + * @see DATAREST-910 + */ + @Test + public void shouldTranslatePathForSingleLevelJsonUnwrappedObject() { + + Sort translatedSort = sortTranslator.translateSort(new Sort("un-name"), + mappingContext.getPersistentEntity(UnwrapEmbedded.class)); + + assertThat(translatedSort.getOrderFor("embedded.name"), is(notNullValue())); + } + + /** + * @see DATAREST-910 + */ + @Test + public void shouldTranslatePathForMultiLevelLevelJsonUnwrappedObject() { + + Sort translatedSort = sortTranslator.translateSort(new Sort("un-name", "burrito.un-name"), + mappingContext.getPersistentEntity(MultiUnwrapped.class)); + + assertThat(translatedSort.getOrderFor("anotherWrap.embedded.name"), is(notNullValue())); + assertThat(translatedSort.getOrderFor("burrito.embedded.name"), is(notNullValue())); + } + static class Plain { + public String name; + public Embedded embedded; + @Reference public Embedded refEmbedded; + } + + static class UnwrapEmbedded { + @JsonUnwrapped(prefix = "un-") public Embedded embedded; + } + + static class MultiUnwrapped { + + public String name; + @JsonUnwrapped public UnwrapEmbedded anotherWrap; + public UnwrapEmbedded burrito; + } + + static class Embedded { + + public String name; + public List collection; + public SomeInterface someInterface; } static class WithJsonProperty { - public @JsonProperty("foo") String name; + + @JsonProperty("foo") public String name; + @JsonProperty("em") public EmbeddedWithJsonProperty embeddedWithJsonProperty; } + + static class EmbeddedWithJsonProperty { + @JsonProperty("foo") public String bar; + } + + static interface SomeInterface {} } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/WrappedPropertiesUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/WrappedPropertiesUnitTests.java new file mode 100644 index 000000000..8975253df --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/WrappedPropertiesUnitTests.java @@ -0,0 +1,207 @@ +/* + * 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.MatcherAssert.*; +import static org.hamcrest.Matchers.*; + +import java.util.Collections; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.data.keyvalue.core.mapping.context.KeyValueMappingContext; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.context.PersistentEntities; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Unit tests for {@link WrappedProperties}. + * + * @author Mark Paluch + */ +public class WrappedPropertiesUnitTests { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private KeyValueMappingContext mappingContext; + private PersistentEntities persistentEntities; + + @Before + public void setUp() { + + mappingContext = new KeyValueMappingContext(); + mappingContext.getPersistentEntity(MultiLevelNesting.class); + mappingContext.getPersistentEntity(SyntheticProperties.class); + persistentEntities = new PersistentEntities(Collections.singleton(mappingContext)); + } + + /** + * @see DATAREST-910 + */ + @Test + public void wrappedPropertiesShouldConsiderSingleLevelUnwrapping() { + + PersistentEntity persistentEntity = persistentEntities.getPersistentEntity(OneLevelNesting.class); + WrappedProperties wrappedProperties = WrappedProperties.fromJacksonProperties(persistentEntities, persistentEntity, + MAPPER); + + assertThat(wrappedProperties.hasPersistentPropertiesForField("street"), is(true)); + assertThat(wrappedProperties.hasPersistentPropertiesForField("one"), is(false)); + + List> street = wrappedProperties.getPersistentProperties("street"); + + PersistentProperty addressProperty = persistentEntity.getPersistentProperty("address"); + PersistentProperty streetProperty = persistentEntities.getPersistentEntity(Address.class) + .getPersistentProperty("street"); + + assertThat(street, contains(addressProperty, streetProperty)); + } + + /** + * @see DATAREST-910 + */ + @Test + public void wrappedPropertiesShouldConsiderMultiLevelUnwrapping() { + + PersistentEntity persistentEntity = persistentEntities.getPersistentEntity(MultiLevelNesting.class); + WrappedProperties wrappedProperties = WrappedProperties.fromJacksonProperties(persistentEntities, persistentEntity, + new ObjectMapper()); + + assertThat(wrappedProperties.hasPersistentPropertiesForField("pre-one-post"), is(true)); + assertThat(wrappedProperties.hasPersistentPropertiesForField("pre-street-post"), is(true)); + assertThat(wrappedProperties.hasPersistentPropertiesForField("nested"), is(false)); + + List> street = wrappedProperties.getPersistentProperties("pre-street-post"); + + PersistentProperty oneLevelNestingProperty = persistentEntity.getPersistentProperty("unwrapped"); + PersistentProperty addressProperty = persistentEntities.getPersistentEntity(OneLevelNesting.class) + .getPersistentProperty("address"); + PersistentProperty streetProperty = persistentEntities.getPersistentEntity(Address.class) + .getPersistentProperty("street"); + + assertThat(street, contains(oneLevelNestingProperty, addressProperty, streetProperty)); + } + + /** + * @see DATAREST-910 + */ + @Test + public void wrappedPropertiesShouldConsiderJacksonFieldNames() { + + PersistentEntity persistentEntity = persistentEntities.getPersistentEntity(MultiLevelNesting.class); + WrappedProperties wrappedProperties = WrappedProperties.fromJacksonProperties(persistentEntities, persistentEntity, + new ObjectMapper()); + + assertThat(wrappedProperties.hasPersistentPropertiesForField("pre-zip-post"), is(true)); + } + + /** + * @see DATAREST-910 + */ + @Test + public void wrappedPropertiesShouldIgnoreIgnoredJacksonFields() { + + PersistentEntity persistentEntity = persistentEntities.getPersistentEntity(MultiLevelNesting.class); + WrappedProperties wrappedProperties = WrappedProperties.fromJacksonProperties(persistentEntities, persistentEntity, + new ObjectMapper()); + + assertThat(wrappedProperties.hasPersistentPropertiesForField("pre-street-ignored"), is(false)); + } + + /** + * @see DATAREST-910 + */ + @Test + public void wrappedPropertiesShouldIgnoreSyntheticProperties() { + + PersistentEntity persistentEntity = persistentEntities.getPersistentEntity(SyntheticProperties.class); + WrappedProperties wrappedProperties = WrappedProperties.fromJacksonProperties(persistentEntities, persistentEntity, + new ObjectMapper()); + + assertThat(wrappedProperties.hasPersistentPropertiesForField("street"), is(false)); + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class OneLevelNesting { + + String one; + @JsonUnwrapped Address address; + } + + static class SyntheticProperties { + + @JsonUnwrapped + Address getUnwrapped() { + return null; + } + + MultiLevelNesting getSynthetic() { + return null; + } + + @JsonUnwrapped + void setWrapped(OneLevelNesting address) {} + } + + /** + *
+	 * 
+	  {
+		"multi": "multi",
+		"pre-one-post": "one",
+		"pre-street-post": "street",
+		"pre-zip-post": "zip",
+		"nested": {
+		  "one": "one",
+		  "street": "street",
+		  "zip": "zip"
+		}
+		}
+	  
+	 * 
+ */ + @Data + @AllArgsConstructor + @NoArgsConstructor + static class MultiLevelNesting { + + String multi; + @JsonUnwrapped(prefix = "pre-", suffix = "-post") OneLevelNesting unwrapped; + @JsonIgnore @JsonUnwrapped(prefix = "pre-", suffix = "-ignored") OneLevelNesting ignored; + @JsonUnwrapped(enabled = false) OneLevelNesting nested; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class Address { + + String street; + @JsonProperty("zip") String zipCode; + } +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/util/UriUtilsUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/util/UriUtilsUnitTests.java new file mode 100644 index 000000000..aba5fd072 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/util/UriUtilsUnitTests.java @@ -0,0 +1,72 @@ +/* + * 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.util; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; + +import java.lang.reflect.Method; +import java.util.List; + +import org.junit.Test; +import org.springframework.util.ClassUtils; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * Unit tests for {@link UriUtils}. + * + * @author Mark Paluch + */ +public class UriUtilsUnitTests { + + /** + * @see DATAREST-910 + */ + @Test + public void pathSegmentsShouldDiscoverPathUsingMethodMapping() throws Exception { + + Method method = ClassUtils.getMethod(MappedMethod.class, "method"); + List pathSegments = UriUtils.getPathSegments(method); + + assertThat(pathSegments, hasItems("hello", "world")); + } + + /** + * @see DATAREST-910 + */ + @Test + public void pathSegmentsShouldDiscoverPathUsingTypeAndMethodMapping() throws Exception { + + Method method = ClassUtils.getMethod(MappedClassAndMethod.class, "method"); + List pathSegments = UriUtils.getPathSegments(method); + + assertThat(pathSegments, hasItems("hello", "world")); + } + + static class MappedMethod { + + @RequestMapping("hello/world") + public void method() {} + } + + @RequestMapping("hello") + static class MappedClassAndMethod { + + @RequestMapping("world") + public void method() {} + } +}