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() {} + } +}