From eec2b43fd08885ae4f3ebe87a5f86ef249970d8f Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Sun, 17 Jul 2016 12:32:59 +0200 Subject: [PATCH] DATACMNS-885 - Support for binding JSON payloads to projection interfaces. Projection types annotated with @ProjectedPayload can now be used as parameters for @RequestBody annotated Spring MVC controller method parameters. Accessor methods will be translated into JSON path property expressions which can be customized by using the @JsonPath annotation. The methods are allowed to return simple types, nested projection interfaces or complex classes which will will be mapped using a Jackson ObjectMapper. --- pom.xml | 7 + .../data/projection/Accessor.java | 78 ++++++ .../MapAccessingMethodInterceptor.java | 74 +----- .../projection/MethodInterceptorFactory.java | 50 ++++ .../data/projection/ProjectionFactory.java | 4 +- .../projection/ProxyProjectionFactory.java | 108 ++++++++- .../springframework/data/web/JsonPath.java | 42 ++++ ...sonProjectingMethodInterceptorFactory.java | 167 +++++++++++++ .../data/web/ProjectedPayload.java | 37 +++ ...rojectingJackson2HttpMessageConverter.java | 141 +++++++++++ .../config/SpringDataWebConfiguration.java | 26 +- ...tingMethodInterceptorFactoryUnitTests.java | 227 ++++++++++++++++++ ...Jackson2HttpMessageConverterUnitTests.java | 66 +++++ template.mf | 3 + 14 files changed, 952 insertions(+), 78 deletions(-) create mode 100644 src/main/java/org/springframework/data/projection/Accessor.java create mode 100644 src/main/java/org/springframework/data/projection/MethodInterceptorFactory.java create mode 100644 src/main/java/org/springframework/data/web/JsonPath.java create mode 100644 src/main/java/org/springframework/data/web/JsonProjectingMethodInterceptorFactory.java create mode 100644 src/main/java/org/springframework/data/web/ProjectedPayload.java create mode 100644 src/main/java/org/springframework/data/web/ProjectingJackson2HttpMessageConverter.java create mode 100644 src/test/java/org/springframework/data/web/JsonProjectingMethodInterceptorFactoryUnitTests.java create mode 100644 src/test/java/org/springframework/data/web/ProjectingJackson2HttpMessageConverterUnitTests.java diff --git a/pom.xml b/pom.xml index 3480731b3..86b80f697 100644 --- a/pom.xml +++ b/pom.xml @@ -206,6 +206,13 @@ test + + com.jayway.jsonpath + json-path + ${jsonpath} + true + + diff --git a/src/main/java/org/springframework/data/projection/Accessor.java b/src/main/java/org/springframework/data/projection/Accessor.java new file mode 100644 index 000000000..dc0602562 --- /dev/null +++ b/src/main/java/org/springframework/data/projection/Accessor.java @@ -0,0 +1,78 @@ +/* + * 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.projection; + +import java.beans.PropertyDescriptor; +import java.lang.reflect.Method; + +import org.springframework.beans.BeanUtils; +import org.springframework.util.Assert; + +/** + * Helper value to abstract an accessor. + * + * @author Oliver Gierke + * @soundtrack Benny Greb - Soulfood (Live) + * @since 1.13 + */ +public final class Accessor { + + private final PropertyDescriptor descriptor; + private final Method method; + + /** + * Creates an {@link Accessor} for the given {@link Method}. + * + * @param method must not be {@literal null}. + * @throws IllegalArgumentException in case the given method is not an accessor method. + */ + public Accessor(Method method) { + + Assert.notNull(method, "Method must not be null!"); + + this.descriptor = BeanUtils.findPropertyForMethod(method); + this.method = method; + + Assert.notNull(descriptor, String.format("Invoked method %s is no accessor method!", method)); + } + + /** + * Returns whether the accessor is a getter. + * + * @return + */ + public boolean isGetter() { + return method.equals(descriptor.getReadMethod()); + } + + /** + * Returns whether the accessor is a setter. + * + * @return + */ + public boolean isSetter() { + return method.equals(descriptor.getWriteMethod()); + } + + /** + * Returns the name of the property this accessor handles. + * + * @return will never be {@literal null}. + */ + public String getPropertyName() { + return descriptor.getName(); + } +} diff --git a/src/main/java/org/springframework/data/projection/MapAccessingMethodInterceptor.java b/src/main/java/org/springframework/data/projection/MapAccessingMethodInterceptor.java index c02c22980..bf856c38a 100644 --- a/src/main/java/org/springframework/data/projection/MapAccessingMethodInterceptor.java +++ b/src/main/java/org/springframework/data/projection/MapAccessingMethodInterceptor.java @@ -15,14 +15,14 @@ */ package org.springframework.data.projection; -import java.beans.PropertyDescriptor; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + import java.lang.reflect.Method; import java.util.Map; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; -import org.springframework.beans.BeanUtils; -import org.springframework.util.Assert; import org.springframework.util.ReflectionUtils; /** @@ -31,20 +31,10 @@ import org.springframework.util.ReflectionUtils; * @author Oliver Gierke * @since 1.10 */ +@RequiredArgsConstructor class MapAccessingMethodInterceptor implements MethodInterceptor { - private final Map map; - - /** - * Creates a new {@link MapAccessingMethodInterceptor} for the given {@link Map}. - * - * @param map must not be {@literal null}. - */ - public MapAccessingMethodInterceptor(Map map) { - - Assert.notNull(map, "Map must not be null!"); - this.map = map; - } + private final @NonNull Map map; /* * (non-Javadoc) @@ -70,58 +60,4 @@ class MapAccessingMethodInterceptor implements MethodInterceptor { throw new IllegalStateException("Should never get here!"); } - - /** - * Helper value to abstract an accessor. - * - * @author Oliver Gierke - */ - private static final class Accessor { - - private final PropertyDescriptor descriptor; - private final Method method; - - /** - * Creates an {@link Accessor} for the given {@link Method}. - * - * @param method must not be {@literal null}. - * @throws IllegalArgumentException in case the given method is not an accessor method. - */ - public Accessor(Method method) { - - Assert.notNull(method, "Method must not be null!"); - - this.descriptor = BeanUtils.findPropertyForMethod(method); - this.method = method; - - Assert.notNull(descriptor, String.format("Invoked method %s is no accessor method!", method)); - } - - /** - * Returns whether the acessor is a getter. - * - * @return - */ - public boolean isGetter() { - return method.equals(descriptor.getReadMethod()); - } - - /** - * Returns whether the accessor is a setter. - * - * @return - */ - public boolean isSetter() { - return method.equals(descriptor.getWriteMethod()); - } - - /** - * Returns the name of the property this accessor handles. - * - * @return will never be {@literal null}. - */ - public String getPropertyName() { - return descriptor.getName(); - } - } } diff --git a/src/main/java/org/springframework/data/projection/MethodInterceptorFactory.java b/src/main/java/org/springframework/data/projection/MethodInterceptorFactory.java new file mode 100644 index 000000000..ec068ed4d --- /dev/null +++ b/src/main/java/org/springframework/data/projection/MethodInterceptorFactory.java @@ -0,0 +1,50 @@ +/* + * 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.projection; + +import org.aopalliance.intercept.MethodInterceptor; + +/** + * SPI to create {@link MethodInterceptor} instances based on the given source object and the target type to produce. To + * be registered with a {@link ProxyProjectionFactory} to customize the way method executions on projection proxies are + * handled. + * + * @author Oliver Gierke + * @see ProxyProjectionFactory + * @soundtrack Henrik Freischlader Trio - Nobody Else To Blame (Openness) + * @since 1.13 + */ +public interface MethodInterceptorFactory { + + /** + * Returns the {@link MethodInterceptor} to be used for the given source object and target type. + * + * @param source will never be {@literal null}. + * @param targetType will never be {@literal null}. + * @return + */ + MethodInterceptor createMethodInterceptor(Object source, Class targetType); + + /** + * Returns whether the current factory is supposed to be used to create a {@link MethodInterceptor} for proxy of the + * given target type. + * + * @param source will never be {@literal null}. + * @param targetType will never be {@literal null}. + * @return + */ + boolean supports(Object source, Class targetType); +} diff --git a/src/main/java/org/springframework/data/projection/ProjectionFactory.java b/src/main/java/org/springframework/data/projection/ProjectionFactory.java index a4cfbb376..6e080632e 100644 --- a/src/main/java/org/springframework/data/projection/ProjectionFactory.java +++ b/src/main/java/org/springframework/data/projection/ProjectionFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2015 the original author or authors. + * Copyright 2014-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. @@ -37,7 +37,7 @@ public interface ProjectionFactory { T createProjection(Class projectionType, Object source); /** - * Creates a pojection instance for the given type. + * Creates a projection instance for the given type. * * @param projectionType the type to create, must not be {@literal null}. * @return diff --git a/src/main/java/org/springframework/data/projection/ProxyProjectionFactory.java b/src/main/java/org/springframework/data/projection/ProxyProjectionFactory.java index c2a653c83..94f07e004 100644 --- a/src/main/java/org/springframework/data/projection/ProxyProjectionFactory.java +++ b/src/main/java/org/springframework/data/projection/ProxyProjectionFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2105 the original author or authors. + * Copyright 2014-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. @@ -47,8 +47,19 @@ class ProxyProjectionFactory implements ProjectionFactory, ResourceLoaderAware, private static final boolean IS_JAVA_8 = org.springframework.util.ClassUtils.isPresent("java.util.Optional", ProxyProjectionFactory.class.getClassLoader()); + private final List factories; private ClassLoader classLoader; + /** + * Creates a new {@link ProxyProjectionFactory}. + */ + protected ProxyProjectionFactory() { + + this.factories = new ArrayList(); + this.factories.add(MapAccessingMethodInterceptorFactory.INSTANCE); + this.factories.add(PropertyAccessingMethodInvokerFactory.INSTANCE); + } + /** * @see org.springframework.context.ResourceLoaderAware#setResourceLoader(org.springframework.core.io.ResourceLoader) * @deprecated rather set the {@link ClassLoader} directly via {@link #setBeanClassLoader(ClassLoader)}. @@ -68,6 +79,20 @@ class ProxyProjectionFactory implements ProjectionFactory, ResourceLoaderAware, this.classLoader = classLoader; } + /** + * Registers the given {@link MethodInterceptorFactory} to be used with the factory. Factories registered later enjoy + * precedence over previously registered ones. + * + * @param factory must not be {@literal null}. + * @since 1.13 + */ + public void registerMethodInvokerFactory(MethodInterceptorFactory factory) { + + Assert.notNull(factory, "MethodInterceptorFactory must not be null!"); + + this.factories.add(0, factory); + } + /* * (non-Javadoc) * @see org.springframework.data.rest.core.projection.ProjectionFactory#createProjection(java.lang.Object, java.lang.Class) @@ -144,17 +169,33 @@ class ProxyProjectionFactory implements ProjectionFactory, ResourceLoaderAware, * @param projectionType must not be {@literal null}. * @return */ - @SuppressWarnings("unchecked") private MethodInterceptor getMethodInterceptor(Object source, Class projectionType) { - MethodInterceptor propertyInvocationInterceptor = source instanceof Map - ? new MapAccessingMethodInterceptor((Map) source) - : new PropertyAccessingMethodInterceptor(source); + MethodInterceptor propertyInvocationInterceptor = getFactoryFor(source, projectionType) + .createMethodInterceptor(source, projectionType); return new ProjectingMethodInterceptor(this, postProcessAccessorInterceptor(propertyInvocationInterceptor, source, projectionType)); } + /** + * Returns the {@link MethodInterceptorFactory} to be used with the given source object and target type. + * + * @param source must not be {@literal null}. + * @param projectionType must not be {@literal null}. + * @return + */ + private MethodInterceptorFactory getFactoryFor(Object source, Class projectionType) { + + for (MethodInterceptorFactory factory : factories) { + if (factory.supports(source, projectionType)) { + return factory; + } + } + + throw new IllegalStateException("No MethodInterceptorFactory found for type ".concat(source.getClass().getName())); + } + /** * Post-process the given {@link MethodInterceptor} for the given source instance and projection type. Default * implementation will simply return the given interceptor. @@ -218,4 +259,61 @@ class ProxyProjectionFactory implements ProjectionFactory, ResourceLoaderAware, return invocation.proceed(); } } + + /** + * {@link MethodInterceptorFactory} handling {@link Map}s as target objects. + * + * @author Oliver Gierke + */ + private static enum MapAccessingMethodInterceptorFactory implements MethodInterceptorFactory { + + INSTANCE; + + /* + * (non-Javadoc) + * @see org.springframework.data.projection.MethodInterceptorFactory#createMethodInterceptor(java.lang.Object, java.lang.Class) + */ + @Override + @SuppressWarnings("unchecked") + public MethodInterceptor createMethodInterceptor(Object source, Class targetType) { + return new MapAccessingMethodInterceptor((Map) source); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.projection.MethodInterceptorFactory#supports(java.lang.Object, java.lang.Class) + */ + @Override + public boolean supports(Object source, Class targetType) { + return Map.class.isInstance(source); + } + } + + /** + * {@link MethodInterceptorFactory} to create a {@link PropertyAccessingMethodInterceptor} for arbitrary objects. + * + * @author Oliver Gierke + */ + private static enum PropertyAccessingMethodInvokerFactory implements MethodInterceptorFactory { + + INSTANCE; + + /* + * (non-Javadoc) + * @see org.springframework.data.projection.MethodInterceptorFactory#createMethodInterceptor(java.lang.Object, java.lang.Class) + */ + @Override + public MethodInterceptor createMethodInterceptor(Object source, Class targetType) { + return new PropertyAccessingMethodInterceptor(source); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.projection.MethodInterceptorFactory#supports(java.lang.Object, java.lang.Class) + */ + @Override + public boolean supports(Object source, Class targetType) { + return true; + } + } } diff --git a/src/main/java/org/springframework/data/web/JsonPath.java b/src/main/java/org/springframework/data/web/JsonPath.java new file mode 100644 index 000000000..968b17840 --- /dev/null +++ b/src/main/java/org/springframework/data/web/JsonPath.java @@ -0,0 +1,42 @@ +/* + * 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.web; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to explicitly declare a JSON Path expression on a projection interface. + * + * @author Oliver Gierke + * @soundtrack Andy McKee - RyLynn (Live book) + * @since 1.13 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +public @interface JsonPath { + + /** + * The JSON Path expression to be evaluated when the annotated method is invoked. + * + * @return + */ + String value(); +} diff --git a/src/main/java/org/springframework/data/web/JsonProjectingMethodInterceptorFactory.java b/src/main/java/org/springframework/data/web/JsonProjectingMethodInterceptorFactory.java new file mode 100644 index 000000000..58a43328f --- /dev/null +++ b/src/main/java/org/springframework/data/web/JsonProjectingMethodInterceptorFactory.java @@ -0,0 +1,167 @@ +/* + * 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.web; + +import lombok.RequiredArgsConstructor; +import net.minidev.json.JSONArray; +import net.minidev.json.JSONObject; + +import java.io.InputStream; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.Map; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.data.projection.Accessor; +import org.springframework.data.projection.MethodInterceptorFactory; +import org.springframework.data.util.ClassTypeInformation; +import org.springframework.data.util.TypeInformation; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.ParseContext; +import com.jayway.jsonpath.TypeRef; +import com.jayway.jsonpath.spi.mapper.MappingProvider; + +/** + * {@link MethodInterceptorFactory} to create a {@link MethodInterceptor} that will + * + * @author Oliver Gierke + * @soundtrack Jeff Coffin - Fruitcake (The Inside Of The Outside) + * @since 1.13 + */ +public class JsonProjectingMethodInterceptorFactory implements MethodInterceptorFactory { + + private final ParseContext context; + + /** + * Creates a new {@link JsonProjectingMethodInterceptorFactory} using the given {@link ObjectMapper}. + * + * @param mapper must not be {@literal null}. + */ + public JsonProjectingMethodInterceptorFactory(MappingProvider mappingProvider) { + + Assert.notNull(mappingProvider, "MappingProvider must not be null!"); + + Configuration build = Configuration.builder()// + .mappingProvider(mappingProvider)// + .build(); + + this.context = JsonPath.using(build); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.projection.MethodInterceptorFactory#createMethodInterceptor(java.lang.Object, java.lang.Class) + */ + @Override + public MethodInterceptor createMethodInterceptor(Object source, Class targetType) { + + DocumentContext context = InputStream.class.isInstance(source) ? this.context.parse((InputStream) source) + : this.context.parse(source); + + return new InputMessageProjecting(context); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.projection.MethodInterceptorFactory#supports(java.lang.Object, java.lang.Class) + */ + @Override + public boolean supports(Object source, Class targetType) { + + if (InputStream.class.isInstance(source) || JSONObject.class.isInstance(source) + || JSONArray.class.isInstance(source)) { + return true; + } + + return Map.class.isInstance(source) && hasJsonPathAnnotation(targetType); + } + + /** + * Returns whether the given type contains a method with a {@link org.springframework.data.web.JsonPath} annotation. + * + * @param type must not be {@literal null}. + * @return + */ + private static boolean hasJsonPathAnnotation(Class type) { + + for (Method method : type.getMethods()) { + if (AnnotationUtils.findAnnotation(method, org.springframework.data.web.JsonPath.class) != null) { + return true; + } + } + + return false; + } + + @RequiredArgsConstructor + private static class InputMessageProjecting implements MethodInterceptor { + + private final DocumentContext context; + + /* + * (non-Javadoc) + * @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation) + */ + @Override + public Object invoke(MethodInvocation invocation) throws Throwable { + + Method method = invocation.getMethod(); + TypeInformation returnType = ClassTypeInformation.fromReturnTypeOf(method); + ResolvableType type = ResolvableType.forMethodReturnType(method); + String jsonPath = getJsonPath(method); + + return !returnType.getActualType().getType().isInterface() ? context.read(jsonPath, new ResolvableTypeRef(type)) + : context.read(jsonPath); + } + + /** + * Returns the JSONPath expression to be used for the given method. + * + * @param method + * @return + */ + private static String getJsonPath(Method method) { + + org.springframework.data.web.JsonPath annotation = AnnotationUtils.findAnnotation(method, + org.springframework.data.web.JsonPath.class); + + return annotation != null ? annotation.value() : "$.".concat(new Accessor(method).getPropertyName()); + } + + @RequiredArgsConstructor + private static class ResolvableTypeRef extends TypeRef { + + private final ResolvableType type; + + /* + * (non-Javadoc) + * @see com.jayway.jsonpath.TypeRef#getType() + */ + @Override + public Type getType() { + return type.getType(); + } + } + } +} diff --git a/src/main/java/org/springframework/data/web/ProjectedPayload.java b/src/main/java/org/springframework/data/web/ProjectedPayload.java new file mode 100644 index 000000000..7400c640a --- /dev/null +++ b/src/main/java/org/springframework/data/web/ProjectedPayload.java @@ -0,0 +1,37 @@ +/* + * 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.web; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.*; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Annotation to mark projection interfaces that are supposed to be used as projection interface to bind request or + * response payloads to. + * + * @author Oliver Gierke + * @soundtrack + * @since 1.13 + */ +@Documented +@Retention(RUNTIME) +@Target(TYPE) +public @interface ProjectedPayload { +} diff --git a/src/main/java/org/springframework/data/web/ProjectingJackson2HttpMessageConverter.java b/src/main/java/org/springframework/data/web/ProjectingJackson2HttpMessageConverter.java new file mode 100644 index 000000000..4d2f16da7 --- /dev/null +++ b/src/main/java/org/springframework/data/web/ProjectingJackson2HttpMessageConverter.java @@ -0,0 +1,141 @@ +/* + * 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.web; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.Map; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.util.Assert; +import org.springframework.util.ConcurrentReferenceHashMap; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; + +/** + * {@link HttpMessageConverter} implementation to enable projected JSON binding to interfaces annotated with + * {@link ProjectedPayload}. + * + * @author Oliver Gierke + * @soundtrack Richard Spaven -Ice Is Nice (Spaven's 5ive) + * @since 1.13 + */ +public class ProjectingJackson2HttpMessageConverter extends MappingJackson2HttpMessageConverter + implements BeanClassLoaderAware, BeanFactoryAware { + + private final SpelAwareProxyProjectionFactory projectionFactory; + private final Map, Boolean> supportedTypesCache = new ConcurrentReferenceHashMap, Boolean>(); + + /** + * Creates a new {@link ProjectingJackson2HttpMessageConverter} using a default {@link ObjectMapper}. + */ + public ProjectingJackson2HttpMessageConverter() { + this.projectionFactory = initProjectionFactory(getObjectMapper()); + } + + /** + * Creates a new {@link ProjectingJackson2HttpMessageConverter} for the given {@link ObjectMapper}. + * + * @param mapper must not be {@literal null}. + */ + public ProjectingJackson2HttpMessageConverter(ObjectMapper mapper) { + + super(mapper); + + this.projectionFactory = initProjectionFactory(mapper); + } + + /** + * Creates a new {@link SpelAwareProxyProjectionFactory} with the {@link JsonProjectingMethodInterceptorFactory} + * registered for the given {@link ObjectMapper}. + * + * @param mapper must not be {@literal null}. + * @return + */ + private static SpelAwareProxyProjectionFactory initProjectionFactory(ObjectMapper mapper) { + + Assert.notNull(mapper, "ObjectMapper must not be null!"); + + SpelAwareProxyProjectionFactory projectionFactory = new SpelAwareProxyProjectionFactory(); + projectionFactory + .registerMethodInvokerFactory(new JsonProjectingMethodInterceptorFactory(new JacksonMappingProvider(mapper))); + + return projectionFactory; + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.BeanClassLoaderAware#setBeanClassLoader(java.lang.ClassLoader) + */ + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + projectionFactory.setBeanClassLoader(classLoader); + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.BeanFactoryAware#setBeanFactory(org.springframework.beans.factory.BeanFactory) + */ + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + projectionFactory.setBeanFactory(beanFactory); + } + + /* + * (non-Javadoc) + * @see org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter#canRead(java.lang.reflect.Type, java.lang.Class, org.springframework.http.MediaType) + */ + @Override + public boolean canRead(Type type, Class contextClass, MediaType mediaType) { + + Class rawType = ResolvableType.forType(type).getRawClass(); + + Boolean result = supportedTypesCache.get(rawType); + + if (result != null) { + return result; + } + + result = canRead(mediaType) && rawType.isInterface() + && AnnotationUtils.findAnnotation(rawType, ProjectedPayload.class) != null; + + supportedTypesCache.put(rawType, result); + + return result; + } + + /* + * (non-Javadoc) + * @see org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter#read(java.lang.reflect.Type, java.lang.Class, org.springframework.http.HttpInputMessage) + */ + @Override + public Object read(Type type, Class contextClass, HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + return projectionFactory.createProjection(ResolvableType.forType(type).getRawClass(), inputMessage.getBody()); + } +} diff --git a/src/main/java/org/springframework/data/web/config/SpringDataWebConfiguration.java b/src/main/java/org/springframework/data/web/config/SpringDataWebConfiguration.java index 644304c00..91cc870f0 100644 --- a/src/main/java/org/springframework/data/web/config/SpringDataWebConfiguration.java +++ b/src/main/java/org/springframework/data/web/config/SpringDataWebConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2015 the original author or authors. + * Copyright 2013-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. @@ -28,13 +28,18 @@ import org.springframework.data.geo.format.DistanceFormatter; import org.springframework.data.geo.format.PointFormatter; import org.springframework.data.repository.support.DomainClassConverter; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.data.web.ProjectingJackson2HttpMessageConverter; import org.springframework.data.web.ProxyingHandlerMethodArgumentResolver; import org.springframework.data.web.SortHandlerMethodArgumentResolver; import org.springframework.format.FormatterRegistry; import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.util.ClassUtils; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; +import com.fasterxml.jackson.databind.ObjectMapper; + /** * Configuration class to register {@link PageableHandlerMethodArgumentResolver}, * {@link SortHandlerMethodArgumentResolver} and {@link DomainClassConverter}. @@ -100,8 +105,25 @@ public class SpringDataWebConfiguration extends WebMvcConfigurerAdapter { ProxyingHandlerMethodArgumentResolver resolver = new ProxyingHandlerMethodArgumentResolver( conversionService.getObject()); resolver.setBeanFactory(context); - resolver.setResourceLoader(context); + resolver.setBeanClassLoader(context.getClassLoader()); argumentResolvers.add(resolver); } + + /* + * (non-Javadoc) + * @see org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter#extendMessageConverters(java.util.List) + */ + @Override + public void extendMessageConverters(List> converters) { + + if (ClassUtils.isPresent("com.jayway.jsonpath.DocumentContext", context.getClassLoader())) { + + ProjectingJackson2HttpMessageConverter converter = new ProjectingJackson2HttpMessageConverter(new ObjectMapper()); + converter.setBeanClassLoader(context.getClassLoader()); + converter.setBeanFactory(context); + + converters.add(0, converter); + } + } } diff --git a/src/test/java/org/springframework/data/web/JsonProjectingMethodInterceptorFactoryUnitTests.java b/src/test/java/org/springframework/data/web/JsonProjectingMethodInterceptorFactoryUnitTests.java new file mode 100644 index 000000000..6ab4a7f17 --- /dev/null +++ b/src/test/java/org/springframework/data/web/JsonProjectingMethodInterceptorFactoryUnitTests.java @@ -0,0 +1,227 @@ +/* + * 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.web; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.Set; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; +import com.jayway.jsonpath.spi.mapper.MappingProvider; + +/** + * Unit tests for {@link JsonProjectingMethodInterceptorFactory}. + * + * @author Oliver Gierke + * @since 1.13 + * @soundtrack Richard Spaven - Assemble (Whole Other*) + */ +public class JsonProjectingMethodInterceptorFactoryUnitTests { + + ProjectionFactory projectionFactory; + Customer customer; + + @Before + public void setUp() { + + String json = "{\"firstname\" : \"Dave\", "// + + "\"address\" : { \"zipCode\" : \"01097\", \"city\" : \"Dresden\" }," // + + "\"addresses\" : [ { \"zipCode\" : \"01097\", \"city\" : \"Dresden\" }]" + " }"; + + SpelAwareProxyProjectionFactory projectionFactory = new SpelAwareProxyProjectionFactory(); + + MappingProvider mappingProvider = new JacksonMappingProvider(new ObjectMapper()); + projectionFactory.registerMethodInvokerFactory(new JsonProjectingMethodInterceptorFactory(mappingProvider)); + + this.projectionFactory = projectionFactory; + this.customer = projectionFactory.createProjection(Customer.class, new ByteArrayInputStream(json.getBytes())); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void accessSimpleProperty() { + assertThat(customer.getFirstname(), is("Dave")); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void accessPropertyWithExplicitAnnotation() { + assertThat(customer.getBar(), is("Dave")); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void accessPropertyWithComplexReturnType() { + assertThat(customer.getAddress(), is(new Address("01097", "Dresden"))); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void accessComplexPropertyWithProjection() { + assertThat(customer.getAddressProjection().getCity(), is("Dresden")); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void accessPropertyWithNestedJsonPath() { + assertThat(customer.getNestedZipCode(), is("01097")); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void accessCollectionProperty() { + assertThat(customer.getAddresses().get(0), is(new Address("01097", "Dresden"))); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void accessPropertyOnNestedProjection() { + assertThat(customer.getAddressProjections().get(0).getZipCode(), is("01097")); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void accessPropertyThatUsesJsonPathProjectionInTurn() { + assertThat(customer.getAnotherAddressProjection().getZipCodeButNotCity(), is("01097")); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void accessCollectionPropertyThatUsesJsonPathProjectionInTurn() { + + List projections = customer.getAnotherAddressProjections(); + + assertThat(projections, hasSize(1)); + assertThat(projections.get(0).getZipCodeButNotCity(), is("01097")); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void accessAsCollectionPropertyThatUsesJsonPathProjectionInTurn() { + + Set projections = customer.getAnotherAddressProjectionAsCollection(); + + assertThat(projections, hasSize(1)); + assertThat(projections.iterator().next().getZipCodeButNotCity(), is("01097")); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void accessNestedPropertyButStayOnRootLevel() { + + Name name = customer.getName(); + + assertThat(name, is(notNullValue())); + assertThat(name.getFirstname(), is("Dave")); + } + + interface Customer { + + String getFirstname(); + + @JsonPath("$") + Name getName(); + + Address getAddress(); + + List
getAddresses(); + + @JsonPath("$.addresses") + List getAddressProjections(); + + @JsonPath("$.firstname") + String getBar(); + + @JsonPath("$.address") + AddressProjection getAddressProjection(); + + @JsonPath("$.address.zipCode") + String getNestedZipCode(); + + @JsonPath("$.address") + AnotherAddressProjection getAnotherAddressProjection(); + + @JsonPath("$.addresses") + List getAnotherAddressProjections(); + + @JsonPath("$.address") + Set getAnotherAddressProjectionAsCollection(); + } + + interface AddressProjection { + + String getZipCode(); + + String getCity(); + } + + interface Name { + + @JsonPath("$.firstname") + String getFirstname(); + + @JsonPath("$.lastname") + String getLastname(); + } + + interface AnotherAddressProjection { + + @JsonPath("$.zipCode") + String getZipCodeButNotCity(); + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class Address { + private String zipCode, city; + } +} diff --git a/src/test/java/org/springframework/data/web/ProjectingJackson2HttpMessageConverterUnitTests.java b/src/test/java/org/springframework/data/web/ProjectingJackson2HttpMessageConverterUnitTests.java new file mode 100644 index 000000000..dab280d4d --- /dev/null +++ b/src/test/java/org/springframework/data/web/ProjectingJackson2HttpMessageConverterUnitTests.java @@ -0,0 +1,66 @@ +/* + * 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.web; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +import org.junit.Test; +import org.springframework.http.MediaType; + +/** + * Unit tests for {@link ProjectingJackson2HttpMessageConverter}. + * + * @author Oliver Gierke + * @soundtrack Richard Spaven - Tribute (Whole Other*) + * @since 1.13 + */ +public class ProjectingJackson2HttpMessageConverterUnitTests { + + ProjectingJackson2HttpMessageConverter converter = new ProjectingJackson2HttpMessageConverter(); + MediaType ANYTHING_JSON = MediaType.parseMediaType("application/*+json"); + + /** + * @see DATCMNS-885 + */ + @Test + public void canReadJsonIntoAnnotatedInterface() { + assertThat(converter.canRead(SampleInterface.class, ANYTHING_JSON), is(true)); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void cannotReadUnannotatedInterface() { + assertThat(converter.canRead(UnannotatedInterface.class, ANYTHING_JSON), is(false)); + } + + /** + * @see DATCMNS-885 + */ + @Test + public void cannotReadClass() { + assertThat(converter.canRead(SampleClass.class, ANYTHING_JSON), is(false)); + } + + @ProjectedPayload + interface SampleInterface {} + + interface UnannotatedInterface {} + + class SampleClass {} +} diff --git a/template.mf b/template.mf index fde9a3cba..498857286 100644 --- a/template.mf +++ b/template.mf @@ -11,12 +11,14 @@ Import-Package: Import-Template: com.fasterxml.jackson.*;version="${jackson:[=.=.=,+1.0.0)}";resolution:=optional, com.google.common.*;version="${guava:[=.=.=,+1.0.0)}";resolution:=optional, + com.jayway.jsonpath.*;version="${jsonpath:[=.=.=,+1.0.0]}";resolution:=optional, com.querydsl.*;version="${querydsl:[=.=.=,+1.0.0)}";resolution:=optional, javax.enterprise.*;version="${cdi:[=.=.=,+1.0.0)}";resolution:=optional, javax.inject.*;version="[1.0.0,2.0.0)";resolution:=optional, javax.servlet.*;version="[2.5.0, 4.0.0)";resolution:=optional, javax.xml.bind.*;version="0";resolution:=optional, javax.xml.transform.*;version="0";resolution:=optional, + net.minidev.json.*;version="${jsonpath:[=.=.=,+1.0.0)}";resolution:=optional, org.aopalliance.*;version="[1.0.0, 2.0.0)";resolution:=optional, org.joda.time.*;version="${jodatime:[=.=.=,+1.0.0)}";resolution:=optional, org.springframework.aop.*;version="${spring:[=.=.=,+1.1.0)}";resolution:=optional, @@ -27,6 +29,7 @@ Import-Template: org.springframework.dao.*;version="${spring:[=.=.=,+1.1.0)}";resolution:=optional, org.springframework.expression.*;version="${spring:[=.=.=,+1.1.0)}";resolution:=optional, org.springframework.format.*;version="${spring:[=.=.=,+1.1.0)}";resolution:=optional, + org.springframework.http.*;version="${spring:[=.=.=,+1.1.0)}";resolution:=optional, org.springframework.hateoas.*;version="${spring-hateoas:[=.=.=,+1.1.0)}";resolution:=optional, org.springframework.oxm.*;version="${spring:[=.=.=,+1.1.0)}";resolution:=optional, org.springframework.scheduling.*;version="${spring:[=.=.=,+1.1.0)}";resolution:=optional,