From 7c3b93fffd0b425ffd18bc5e403eb0c3a147fc4c Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Mon, 13 Apr 2015 08:38:44 +0200 Subject: [PATCH] DATAREST-511 - Support for executing repository methods returning Optionals. Added an UnwrappingRepositoryInvokerFactory that transparently unwraps JDK 8 and Guava Optionals to make sure the consuming code works with values or plain nulls correctly. --- spring-data-rest-core/pom.xml | 7 + .../UnwrappingRepositoryInvokerFactory.java | 243 ++++++++++++++++++ ...pingRepositoryInvokerFactoryUnitTests.java | 105 ++++++++ .../RepositoryRestMvcConfiguration.java | 4 +- .../data/rest/webmvc/TestMvcClient.java | 23 ++ .../rest/webmvc/mongodb/MongoWebTests.java | 14 + .../webmvc/mongodb/ProfileRepository.java | 8 +- 7 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/support/UnwrappingRepositoryInvokerFactory.java create mode 100644 spring-data-rest-core/src/test/java/org/springframework/data/rest/core/support/UnwrappingRepositoryInvokerFactoryUnitTests.java diff --git a/spring-data-rest-core/pom.xml b/spring-data-rest-core/pom.xml index 981b65a0a..3c1933bab 100644 --- a/spring-data-rest-core/pom.xml +++ b/spring-data-rest-core/pom.xml @@ -65,6 +65,13 @@ ${jackson} + + com.google.guava + guava + ${guava} + true + + diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/support/UnwrappingRepositoryInvokerFactory.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/support/UnwrappingRepositoryInvokerFactory.java new file mode 100644 index 000000000..a17554abc --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/support/UnwrappingRepositoryInvokerFactory.java @@ -0,0 +1,243 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.rest.core.support; + +import java.io.Serializable; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.support.RepositoryInvoker; +import org.springframework.data.repository.support.RepositoryInvokerFactory; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.MultiValueMap; + +/** + * {@link RepositoryInvokerFactory} that wraps the {@link RepositoryInvokerFactory} returned by the delegate with one + * that automatically unwraps JDK 8 {@link Optional} and Guava {@link com.google.common.base.Optional}s. + * + * @author Oliver Gierke + */ +public class UnwrappingRepositoryInvokerFactory implements RepositoryInvokerFactory { + + private static final List> CONVERTERS; + + static { + + List> converters = new ArrayList>(); + ClassLoader classLoader = UnwrappingRepositoryInvokerFactory.class.getClassLoader(); + + // Add unwrapper for Java 8 Optional + + if (ClassUtils.isPresent("java.util.Optional", classLoader)) { + converters.add(new Converter() { + @Override + public Object convert(Object source) { + return source instanceof Optional ? ((Optional) source).orElse(null) : source; + } + }); + } + + // Add unwrapper for Guava Optional + + if (ClassUtils.isPresent("com.google.common.base.Optional", classLoader)) { + + converters.add(new Converter() { + @Override + public Object convert(Object source) { + return source instanceof com.google.common.base.Optional ? ((com.google.common.base.Optional) source) + .orNull() : source; + } + }); + } + + CONVERTERS = Collections.unmodifiableList(converters); + } + + private final RepositoryInvokerFactory delegate; + + /** + * Creates a new {@link UnwrappingRepositoryInvokerFactory}. + * + * @param delegate must not be {@literal null}. + */ + public UnwrappingRepositoryInvokerFactory(RepositoryInvokerFactory delegate) { + + Assert.notNull(delegate, "Delegate RepositoryInvokerFactory must not be null!"); + + this.delegate = delegate; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryInvokerFactory#getInvokerFor(java.lang.Class) + */ + @Override + public RepositoryInvoker getInvokerFor(Class domainType) { + return new UnwrappingRepositoryInvoker(delegate.getInvokerFor(domainType), CONVERTERS); + } + + /** + * {@link RepositoryInvoker} that post-processes invocations of {@link RepositoryInvoker#invokeFindOne(Serializable)} + * and {@link #invokeQueryMethod(Method, MultiValueMap, Pageable, Sort)} using the given {@link Converter}s. + * + * @author Oliver Gierke + */ + private static class UnwrappingRepositoryInvoker implements RepositoryInvoker { + + private final RepositoryInvoker delegate; + private final Collection> converters; + + /** + * Creates a new {@link UnwrappingRepositoryInvoker} for the given delegate and {@link Converter}s. + * + * @param delegate must not be {@literal null}. + * @param converters must not be {@literal null}. + */ + public UnwrappingRepositoryInvoker(RepositoryInvoker delegate, Collection> converters) { + + Assert.notNull(delegate, "Delegate RepositoryInvoker must not be null!"); + Assert.notNull(converters, "Converters must not be null!"); + + this.delegate = delegate; + this.converters = converters; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryInvoker#invokeFindOne(java.io.Serializable) + */ + @SuppressWarnings("unchecked") + public T invokeFindOne(Serializable id) { + return (T) postProcess(delegate.invokeFindOne(id)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryInvoker#invokeQueryMethod(java.lang.reflect.Method, java.util.Map, org.springframework.data.domain.Pageable, org.springframework.data.domain.Sort) + */ + @Override + @SuppressWarnings("deprecation") + public Object invokeQueryMethod(Method method, Map parameters, Pageable pageable, Sort sort) { + return postProcess(delegate.invokeQueryMethod(method, parameters, pageable, sort)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryInvoker#invokeQueryMethod(java.lang.reflect.Method, org.springframework.util.MultiValueMap, org.springframework.data.domain.Pageable, org.springframework.data.domain.Sort) + */ + @Override + public Object invokeQueryMethod(Method method, MultiValueMap parameters, + Pageable pageable, Sort sort) { + return postProcess(delegate.invokeQueryMethod(method, parameters, pageable, sort)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryInvocationInformation#hasDeleteMethod() + */ + @Override + public boolean hasDeleteMethod() { + return delegate.hasDeleteMethod(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryInvocationInformation#hasFindAllMethod() + */ + @Override + public boolean hasFindAllMethod() { + return delegate.hasFindAllMethod(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryInvocationInformation#hasFindOneMethod() + */ + @Override + public boolean hasFindOneMethod() { + return delegate.hasFindOneMethod(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryInvocationInformation#hasSaveMethod() + */ + @Override + public boolean hasSaveMethod() { + return delegate.hasSaveMethod(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryInvoker#invokeDelete(java.io.Serializable) + */ + @Override + public void invokeDelete(Serializable id) { + delegate.invokeDelete(id); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryInvoker#invokeFindAll(org.springframework.data.domain.Pageable) + */ + @Override + public Iterable invokeFindAll(Pageable pageable) { + return delegate.invokeFindAll(pageable); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryInvoker#invokeFindAll(org.springframework.data.domain.Sort) + */ + @Override + public Iterable invokeFindAll(Sort sort) { + return delegate.invokeFindAll(sort); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryInvoker#invokeSave(java.lang.Object) + */ + @Override + public T invokeSave(T object) { + return delegate.invokeSave(object); + } + + /** + * Invokes the configured converters for the given result. + * + * @param result can be {@literal null}. + * @return + */ + private Object postProcess(Object result) { + + for (Converter converter : converters) { + result = converter.convert(result); + } + + return result; + } + } +} diff --git a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/support/UnwrappingRepositoryInvokerFactoryUnitTests.java b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/support/UnwrappingRepositoryInvokerFactoryUnitTests.java new file mode 100644 index 000000000..b0a98d343 --- /dev/null +++ b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/support/UnwrappingRepositoryInvokerFactoryUnitTests.java @@ -0,0 +1,105 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.rest.core.support; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collection; +import java.util.Optional; + +import org.hamcrest.Matcher; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; +import org.springframework.data.repository.support.RepositoryInvoker; +import org.springframework.data.repository.support.RepositoryInvokerFactory; +import org.springframework.util.LinkedMultiValueMap; + +/** + * Unit tests for {@link UnwrappingRepositoryInvokerFactory}. + * + * @author Oliver Gierke + */ +@RunWith(Parameterized.class) +public class UnwrappingRepositoryInvokerFactoryUnitTests { + + static final Object REFERENCE = new Object(); + + RepositoryInvokerFactory delegate = mock(RepositoryInvokerFactory.class); + RepositoryInvoker invoker = mock(RepositoryInvoker.class); + + RepositoryInvokerFactory factory; + Method method; + + public @Parameter(value = 0) Object source; + public @Parameter(value = 1) Matcher value; + + @Before + public void setUp() throws Exception { + + when(delegate.getInvokerFor(Object.class)).thenReturn(invoker); + + this.factory = new UnwrappingRepositoryInvokerFactory(delegate); + this.method = Object.class.getMethod("toString"); + } + + @Parameters + public static Collection data() { + return Arrays.asList(new Object[][] { // + { Optional.empty(), is(nullValue()) }, // + { Optional.of(REFERENCE), is(REFERENCE) }, // + { com.google.common.base.Optional.absent(), is(nullValue()) }, // + { com.google.common.base.Optional.of(REFERENCE), is(REFERENCE) } // + }); + } + + /** + * @see DATAREST-511 + */ + @Test + public void unwrapsValuesForFindOne() { + assertFindOneValueForSource(source, value); + } + + /** + * @see DATAREST-511 + */ + @Test + public void unwrapsValuesForQuery() { + assertQueryValueForSource(source, value); + } + + private void assertFindOneValueForSource(Object source, Matcher value) { + + when(invoker.invokeFindOne(1L)).thenReturn(source); + assertThat(factory.getInvokerFor(Object.class).invokeFindOne(1L), value); + } + + private void assertQueryValueForSource(Object source, Matcher value) { + + when(invoker.invokeQueryMethod(method, new LinkedMultiValueMap(), null, null)).thenReturn(source); + assertThat( + factory.getInvokerFor(Object.class).invokeQueryMethod(method, new LinkedMultiValueMap(), null, + null), value); + } +} 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 210ad7a7f..0132f9feb 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 @@ -65,6 +65,7 @@ import org.springframework.data.rest.core.mapping.ResourceDescription; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.core.support.DomainObjectMerger; import org.springframework.data.rest.core.support.RepositoryRelProvider; +import org.springframework.data.rest.core.support.UnwrappingRepositoryInvokerFactory; import org.springframework.data.rest.webmvc.BasePathAwareController; import org.springframework.data.rest.webmvc.BasePathAwareHandlerMapping; import org.springframework.data.rest.webmvc.BaseUri; @@ -575,7 +576,8 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon @Bean public RepositoryInvokerFactory repositoryInvokerFactory() { - return new DefaultRepositoryInvokerFactory(repositories(), defaultConversionService()); + return new UnwrappingRepositoryInvokerFactory(new DefaultRepositoryInvokerFactory(repositories(), + defaultConversionService())); } @Bean diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/TestMvcClient.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/TestMvcClient.java index abd8372df..2e7e0d0b5 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/TestMvcClient.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/TestMvcClient.java @@ -20,6 +20,8 @@ import static org.junit.Assert.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import java.util.Arrays; +import java.util.Iterator; import java.util.List; import org.springframework.hateoas.Link; @@ -184,6 +186,27 @@ public class TestMvcClient { return discover.get(0); } + /** + * Traverses the given link relations from the root. + * + * @param rels + * @return + * @throws Exception + */ + public Link discoverUnique(String... rels) throws Exception { + + Iterator toTraverse = Arrays.asList(rels).iterator(); + Link lastLink = null; + + while (toTraverse.hasNext()) { + + String rel = toTraverse.next(); + lastLink = lastLink == null ? discoverUnique(rel) : discoverUnique(lastLink, rel); + } + + return lastLink; + } + /** * Given a URI (root), discover the URIs for a given rel. * diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java index e4a2906ca..2e5b32017 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java @@ -301,4 +301,18 @@ public class MongoWebTests extends CommonWebTests { andExpect(status().isNotModified()).// andExpect(header().string(ETAG, is(notNullValue()))); } + + /** + * @see DATAREST-511 + */ + @Test + public void invokesQueryResourceReturningAnOptional() throws Exception { + + Profile profile = repository.findAll().iterator().next(); + + Link link = client.discoverUnique("profiles", "search", "findById"); + + mvc.perform(get(link.expand(profile.getId()).getHref())).// + andExpect(status().isOk()); + } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/ProfileRepository.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/ProfileRepository.java index 10552c059..bedd42ed5 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/ProfileRepository.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/ProfileRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2014 the original author or authors. + * Copyright 2012-2015 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. @@ -16,6 +16,7 @@ package org.springframework.data.rest.webmvc.mongodb; import java.util.List; +import java.util.Optional; import org.springframework.data.repository.PagingAndSortingRepository; import org.springframework.data.repository.query.Param; @@ -32,4 +33,9 @@ public interface ProfileRepository extends PagingAndSortingRepository findById(@Param("id") String id); }